package service import ( "context" "encoding/json" "errors" "reflect" "strings" "sync" "testing" "gorm.io/gorm" "ragflow/internal/common" "ragflow/internal/entity" ) // --------------------------------------------------------------------------- // Fake implementations // --------------------------------------------------------------------------- type fakeSessionStore struct { mu sync.Mutex sessions map[string]*entity.ChatSession dialogs map[string]*entity.Chat dialogExists map[string]bool // key: tenantID|chatID getByIDErr error createErr error updateByIDErr error deleteByIDErr error getDialogErr error // record calls createCalled []*entity.ChatSession updateCalled []struct { id string updates map[string]interface{} } deleteByIDIDs []string } func newFakeSessionStore() *fakeSessionStore { return &fakeSessionStore{ sessions: make(map[string]*entity.ChatSession), dialogs: make(map[string]*entity.Chat), dialogExists: make(map[string]bool), } } func (f *fakeSessionStore) GetByID(id string) (*entity.ChatSession, error) { if f.getByIDErr != nil { return nil, f.getByIDErr } s, ok := f.sessions[id] if !ok { return nil, gorm.ErrRecordNotFound } return s, nil } func (f *fakeSessionStore) GetBySessionIDAndChatID(sessionID, chatID string) (*entity.ChatSession, error) { s, err := f.GetByID(sessionID) if err != nil { return nil, err } if s.DialogID != chatID { return nil, gorm.ErrRecordNotFound } return s, nil } func (f *fakeSessionStore) Create(conv *entity.ChatSession) error { f.mu.Lock() defer f.mu.Unlock() if f.createErr != nil { return f.createErr } f.sessions[conv.ID] = conv f.createCalled = append(f.createCalled, conv) return nil } func (f *fakeSessionStore) UpdateByID(id string, updates map[string]interface{}) error { f.mu.Lock() defer f.mu.Unlock() if f.updateByIDErr != nil { return f.updateByIDErr } s, ok := f.sessions[id] if !ok { return gorm.ErrRecordNotFound } f.updateCalled = append(f.updateCalled, struct { id string updates map[string]interface{} }{id, updates}) for k, v := range updates { switch k { case "name": if str, ok := v.(string); ok { s.Name = &str } case "message": if raw, ok := v.([]byte); ok { s.Message = append(json.RawMessage(nil), raw...) } case "reference": if raw, ok := v.([]byte); ok { s.Reference = append(json.RawMessage(nil), raw...) } } } return nil } func (f *fakeSessionStore) DeleteByID(id string) error { f.mu.Lock() defer f.mu.Unlock() if f.deleteByIDErr != nil { return f.deleteByIDErr } f.deleteByIDIDs = append(f.deleteByIDIDs, id) delete(f.sessions, id) return nil } func (f *fakeSessionStore) ListByChatID(chatID string) ([]*entity.ChatSession, error) { var result []*entity.ChatSession for _, s := range f.sessions { if s.DialogID == chatID { result = append(result, s) } } return result, nil } func (f *fakeSessionStore) GetDialogByID(chatID string) (*entity.Chat, error) { if f.getDialogErr != nil { return nil, f.getDialogErr } d, ok := f.dialogs[chatID] if !ok { return nil, errors.New("dialog not found") } return d, nil } func (f *fakeSessionStore) CheckDialogExists(tenantID, chatID string) (bool, error) { key := tenantID + "|" + chatID return f.dialogExists[key], nil } // --------------------------------------------------------------------------- type fakeTenantStore struct { tenantIDs []string err error } func (f *fakeTenantStore) GetTenantIDsByUserID(userID string) ([]string, error) { return f.tenantIDs, f.err } // --------------------------------------------------------------------------- type fakePipeline struct { resultChan <-chan AsyncChatResult err error } func (f *fakePipeline) AsyncChat(ctx context.Context, chat *entity.Chat, messages []map[string]interface{}, stream bool, kwargs map[string]interface{}) (<-chan AsyncChatResult, error) { return f.resultChan, f.err } func makeResultChan(results ...AsyncChatResult) <-chan AsyncChatResult { ch := make(chan AsyncChatResult, len(results)) for _, r := range results { ch <- r } close(ch) return ch } // =================================================================== // SetChatSession tests // =================================================================== func TestSetChatSession_CreateNew(t *testing.T) { store := newFakeSessionStore() dialog := &entity.Chat{ID: "dialog-1", PromptConfig: entity.JSONMap{"prologue": "Welcome!"}} store.dialogs["dialog-1"] = dialog svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } resp, err := svc.SetChatSession("user-1", &SetChatSessionRequest{ DialogID: "dialog-1", IsNew: true, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.ID == "" { t.Fatal("expected session ID to be generated") } if resp.DialogID != "dialog-1" { t.Fatalf("expected dialog_id=dialog-1, got %s", resp.DialogID) } if len(store.createCalled) != 1 { t.Fatalf("expected 1 Create call, got %d", len(store.createCalled)) } // Verify prologue is in the message list. var msgs []map[string]interface{} if err := json.Unmarshal(store.createCalled[0].Message, &msgs); err != nil { t.Fatalf("failed to unmarshal message: %v", err) } if len(msgs) != 1 { t.Fatalf("expected 1 initial message, got %d", len(msgs)) } firstMsg := msgs[0] if firstMsg["role"] != "assistant" || firstMsg["content"] != "Welcome!" { t.Fatalf("unexpected prologue message: %#v", firstMsg) } } func TestSetChatSession_CreateNewDefaultPrologue(t *testing.T) { store := newFakeSessionStore() store.dialogs["dialog-1"] = &entity.Chat{ID: "dialog-1"} svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } resp, err := svc.SetChatSession("user-1", &SetChatSessionRequest{ DialogID: "dialog-1", IsNew: true, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.ID == "" { t.Fatal("expected session ID") } // Default prologue var msgs []map[string]interface{} json.Unmarshal(store.createCalled[0].Message, &msgs) firstMsg := msgs[0] if !strings.Contains(firstMsg["content"].(string), "Hi! I'm your assistant") { t.Fatalf("expected default prologue, got %q", firstMsg["content"]) } } func TestSetChatSession_CreateNewDialogNotFound(t *testing.T) { store := newFakeSessionStore() svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, err := svc.SetChatSession("user-1", &SetChatSessionRequest{ DialogID: "nonexistent", IsNew: true, }) if err == nil || err.Error() != "Dialog not found" { t.Fatalf("expected 'Dialog not found' error, got %v", err) } } func TestSetChatSession_UpdateExisting(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Name: strPtr("old name"), } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } resp, err := svc.SetChatSession("user-1", &SetChatSessionRequest{ SessionID: "session-1", Name: "new name", IsNew: false, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.ID != "session-1" { t.Fatalf("expected session-1, got %s", resp.ID) } if len(store.updateCalled) != 1 { t.Fatalf("expected UpdateByID call, got %d", len(store.updateCalled)) } } func TestSetChatSession_UpdateNotFound(t *testing.T) { store := newFakeSessionStore() store.updateByIDErr = errors.New("Chat session not found") svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, err := svc.SetChatSession("user-1", &SetChatSessionRequest{ SessionID: "missing", IsNew: false, }) if err == nil || err.Error() != "Chat session not found" { t.Fatalf("expected 'Chat session not found' error, got %v", err) } } func TestSetChatSession_NameTruncation(t *testing.T) { store := newFakeSessionStore() store.dialogs["dialog-1"] = &entity.Chat{ID: "dialog-1"} svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } longName := strings.Repeat("x", 300) resp, err := svc.SetChatSession("user-1", &SetChatSessionRequest{ DialogID: "dialog-1", Name: longName, IsNew: true, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.Name == nil || len(*resp.Name) > 255 { t.Fatalf("expected name truncated to <=255, got len=%d", len(*resp.Name)) } } // =================================================================== // RemoveChatSessions tests // =================================================================== func TestRemoveChatSessions_Success(t *testing.T) { store := newFakeSessionStore() store.sessions["conv-1"] = &entity.ChatSession{ID: "conv-1", DialogID: "dialog-1"} store.sessions["conv-2"] = &entity.ChatSession{ID: "conv-2", DialogID: "dialog-1"} store.dialogExists["user-1|dialog-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-1"}}, pipeline: &fakePipeline{}, } err := svc.RemoveChatSessions("user-1", []string{"conv-1", "conv-2"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(store.deleteByIDIDs) != 2 { t.Fatalf("expected 2 deletes, got %d", len(store.deleteByIDIDs)) } } func TestRemoveChatSessions_SessionNotFound(t *testing.T) { store := newFakeSessionStore() svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-1"}}, pipeline: &fakePipeline{}, } err := svc.RemoveChatSessions("user-1", []string{"missing"}) if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("expected 'not found' error, got %v", err) } } func TestRemoveChatSessions_NotOwner(t *testing.T) { store := newFakeSessionStore() store.sessions["conv-1"] = &entity.ChatSession{ID: "conv-1", DialogID: "dialog-1"} // No tenant matches — dialogExists stays false for all combinations svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-other"}}, pipeline: &fakePipeline{}, } err := svc.RemoveChatSessions("user-1", []string{"conv-1"}) if err == nil || !strings.Contains(err.Error(), "Only owner") { t.Fatalf("expected 'Only owner' error, got %v", err) } } // =================================================================== // ListChatSessions tests // =================================================================== func TestListChatSessions_Success(t *testing.T) { store := newFakeSessionStore() store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"} store.sessions["s2"] = &entity.ChatSession{ID: "s2", DialogID: "chat-1"} store.dialogExists["tenant-1|chat-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-1"}}, pipeline: &fakePipeline{}, } resp, err := svc.ListChatSessions("user-1", "chat-1") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(resp.Sessions) != 2 { t.Fatalf("expected 2 sessions, got %d", len(resp.Sessions)) } } func TestListChatSessions_NotOwner(t *testing.T) { store := newFakeSessionStore() svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-other"}}, pipeline: &fakePipeline{}, } _, err := svc.ListChatSessions("user-1", "chat-1") if err == nil || !strings.Contains(err.Error(), "only owner") { t.Fatalf("expected 'only owner' error, got %v", err) } } // =================================================================== // GetSession / UpdateSession tests // =================================================================== func TestGetSession_Success(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "chat-1", Name: strPtr("session"), Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`), Reference: json.RawMessage(`[ {"chunks":[{"chunk_id":"chunk-1","content_with_weight":"hello","doc_id":"doc-1","docnm_kwd":"Doc 1","kb_id":"kb-1"}]}, [] ]`), UserID: strPtr("user-1"), } icon := "avatar.png" store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", Icon: &icon} store.dialogExists["user-1|chat-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } resp, code, err := svc.GetSession("user-1", "chat-1", "session-1") if err != nil { t.Fatalf("unexpected error: %v", err) } if code != common.CodeSuccess { t.Fatalf("unexpected code: %v", code) } if resp.ChatID != "chat-1" { t.Fatalf("chat_id=%q", resp.ChatID) } if resp.Avatar == nil || *resp.Avatar != "avatar.png" { t.Fatalf("avatar=%v", resp.Avatar) } if len(resp.Messages) != 1 || resp.Messages[0]["content"] != "hello" { t.Fatalf("messages=%#v", resp.Messages) } if len(resp.Reference) != 2 { t.Fatalf("reference len=%d", len(resp.Reference)) } firstRef, ok := resp.Reference[0].(map[string]interface{}) if !ok { t.Fatalf("reference[0] type=%T", resp.Reference[0]) } chunks, ok := firstRef["chunks"].([]FormattedChunk) if !ok { t.Fatalf("chunks type=%T", firstRef["chunks"]) } if len(chunks) != 1 || chunks[0].ID != "chunk-1" { t.Fatalf("chunks=%#v", chunks) } if _, ok := resp.Reference[1].([]interface{}); !ok { t.Fatalf("reference[1] changed unexpectedly: %T", resp.Reference[1]) } } func TestGetSession_NotOwner(t *testing.T) { svc := &ChatSessionService{ chatSessionDAO: newFakeSessionStore(), userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, code, err := svc.GetSession("user-1", "chat-1", "session-1") if err == nil || err.Error() != "No authorization." { t.Fatalf("err=%v", err) } if code != common.CodeAuthenticationError { t.Fatalf("code=%v", code) } } func TestGetSession_WrongChat(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ID: "session-1", DialogID: "chat-2"} store.dialogExists["user-1|chat-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, code, err := svc.GetSession("user-1", "chat-1", "session-1") if err == nil || err.Error() != "Session does not belong to this chat!" { t.Fatalf("err=%v", err) } if code != common.CodeDataError { t.Fatalf("code=%v", code) } } func TestUpdateSession_Success(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "chat-1", Name: strPtr("old"), Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`), } store.dialogExists["user-1|chat-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } longName := " " + strings.Repeat("x", 260) + " " resp, code, err := svc.UpdateSession("user-1", "chat-1", "session-1", map[string]interface{}{ "name": longName, "user_id": "spoof", "chat_id": "spoof-chat", }) if err != nil { t.Fatalf("unexpected error: %v", err) } if code != common.CodeSuccess { t.Fatalf("code=%v", code) } if resp.Name == nil || len(*resp.Name) != 255 { t.Fatalf("name=%v", resp.Name) } if len(store.updateCalled) != 1 { t.Fatalf("update calls=%d", len(store.updateCalled)) } if _, ok := store.updateCalled[0].updates["user_id"]; ok { t.Fatalf("unexpected user_id update: %#v", store.updateCalled[0].updates) } if _, ok := store.updateCalled[0].updates["chat_id"]; ok { t.Fatalf("unexpected chat_id update: %#v", store.updateCalled[0].updates) } if !reflect.DeepEqual(resp.Messages, []map[string]interface{}{{"role": "assistant", "content": "hello"}}) { t.Fatalf("messages=%#v", resp.Messages) } } func TestUpdateSession_ValidationErrors(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ID: "session-1", DialogID: "chat-1"} store.dialogExists["user-1|chat-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } cases := []struct { name string req map[string]interface{} message string code common.ErrorCode }{ {name: "empty body", req: map[string]interface{}{}, message: "Request body cannot be empty", code: common.CodeArgumentError}, {name: "message", req: map[string]interface{}{"message": []interface{}{}}, message: "`messages` cannot be changed.", code: common.CodeDataError}, {name: "messages", req: map[string]interface{}{"messages": []interface{}{}}, message: "`messages` cannot be changed.", code: common.CodeDataError}, {name: "reference", req: map[string]interface{}{"reference": []interface{}{}}, message: "`reference` cannot be changed.", code: common.CodeDataError}, {name: "empty name", req: map[string]interface{}{"name": " "}, message: "`name` can not be empty.", code: common.CodeDataError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, code, err := svc.UpdateSession("user-1", "chat-1", "session-1", tc.req) if err == nil || err.Error() != tc.message { t.Fatalf("err=%v", err) } if code != tc.code { t.Fatalf("code=%v", code) } }) } } func TestUpdateSession_NotFound(t *testing.T) { store := newFakeSessionStore() store.dialogExists["user-1|chat-1"] = true svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, code, err := svc.UpdateSession("user-1", "chat-1", "missing", map[string]interface{}{"name": "renamed"}) if err == nil || err.Error() != "Session not found!" { t.Fatalf("err=%v", err) } if code != common.CodeDataError { t.Fatalf("code=%v", code) } } // =================================================================== // Completion tests // =================================================================== func TestCompletion_Success(t *testing.T) { store := newFakeSessionStore() session := &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`), Reference: json.RawMessage(`[]`), } store.sessions["session-1"] = session store.dialogs["dialog-1"] = &entity.Chat{ ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory", LLMSetting: entity.JSONMap{}, } pipeline := &fakePipeline{ resultChan: makeResultChan( AsyncChatResult{Answer: "Hello", Reference: map[string]interface{}{"chunks": []interface{}{}}}, AsyncChatResult{Answer: " world", Final: true, Reference: map[string]interface{}{"chunks": []interface{}{}}}, ), } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: pipeline, } result, err := svc.Completion("user-1", "session-1", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1") if err != nil { t.Fatalf("unexpected error: %v", err) } ans, _ := result["answer"].(string) if ans != "Hello world" { t.Fatalf("expected answer 'Hello world', got %q", ans) } got := parseMessages(store.sessions["session-1"].Message) if len(got) != 3 { t.Fatalf("stored messages=%#v", got) } if got[0]["role"] != "assistant" || got[0]["content"] != "Welcome!" { t.Fatalf("stored prologue=%#v", got[0]) } if got[1]["role"] != "user" || got[1]["content"] != "hi" { t.Fatalf("stored user message=%#v", got[1]) } if got[2]["role"] != "assistant" || got[2]["content"] != "Hello world" || got[2]["id"] != "msg-1" { t.Fatalf("stored assistant message=%#v", got[2]) } } func TestCompletion_EmptyMessages(t *testing.T) { svc := &ChatSessionService{ chatSessionDAO: &fakeSessionStore{}, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, err := svc.Completion("user-1", "session-1", nil, "", nil, "msg-1") if err == nil || err.Error() != "messages cannot be empty" { t.Fatalf("expected 'messages cannot be empty', got %v", err) } } func TestCompletion_LastMessageNotFromUser(t *testing.T) { svc := &ChatSessionService{ chatSessionDAO: &fakeSessionStore{}, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, err := svc.Completion("user-1", "session-1", []map[string]interface{}{ {"role": "assistant", "content": "hello"}, }, "", nil, "msg-1") if err == nil || !strings.Contains(err.Error(), "not from user") { t.Fatalf("expected 'not from user' error, got %v", err) } } func TestCompletion_ConversationNotFound(t *testing.T) { store := newFakeSessionStore() svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, err := svc.Completion("user-1", "missing", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1") if err == nil || err.Error() != "Conversation not found" { t.Fatalf("expected 'Conversation not found', got %v", err) } } func TestCompletion_DialogNotFound(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Message: json.RawMessage(`[]`), Reference: json.RawMessage(`[]`), } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } _, err := svc.Completion("user-1", "session-1", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1") if err == nil || err.Error() != "Dialog not found" { t.Fatalf("expected 'Dialog not found', got %v", err) } } func TestCompletion_PipelineError(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Message: json.RawMessage(`[]`), Reference: json.RawMessage(`[]`), } store.dialogs["dialog-1"] = &entity.Chat{ ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory", LLMSetting: entity.JSONMap{}, } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{err: errors.New("model unavailable")}, } _, err := svc.Completion("user-1", "session-1", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1") if err == nil || err.Error() != "model unavailable" { t.Fatalf("expected 'model unavailable' error, got %v", err) } } // =================================================================== // CompletionStream tests // =================================================================== func readStreamChan(ch <-chan string, n int) []string { var msgs []string for i := 0; i < n; i++ { select { case msg, ok := <-ch: if !ok { return msgs } msgs = append(msgs, msg) default: return msgs } } return msgs } func TestCompletionStream_Success(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Message: json.RawMessage(`{"messages":[{"role":"assistant","content":"Welcome!"}]}`), Reference: json.RawMessage(`[]`), } store.dialogs["dialog-1"] = &entity.Chat{ ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory", LLMSetting: entity.JSONMap{}, } pipeline := &fakePipeline{ resultChan: makeResultChan( AsyncChatResult{Answer: "stream", Reference: map[string]interface{}{"chunks": []interface{}{}}}, AsyncChatResult{Answer: " answer", Reference: map[string]interface{}{"chunks": []interface{}{}}}, ), } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: pipeline, } streamChan := make(chan string, 10) err := svc.CompletionStream(context.Background(), "user-1", "session-1", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1", streamChan) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should receive data events and final signal msgs := readStreamChan(streamChan, 5) if len(msgs) < 3 { t.Fatalf("expected at least 3 stream messages, got %d: %v", len(msgs), msgs) } // Check final signal finalFound := false for _, m := range msgs { if strings.Contains(m, `"data":true`) { finalFound = true break } } if !finalFound { t.Fatal("expected final=true signal in stream") } got := parseMessages(store.sessions["session-1"].Message) if len(got) != 3 { t.Fatalf("stored messages=%#v", got) } if got[0]["role"] != "assistant" || got[0]["content"] != "Welcome!" { t.Fatalf("stored prologue=%#v", got[0]) } if got[1]["role"] != "user" || got[1]["content"] != "hi" { t.Fatalf("stored user message=%#v", got[1]) } if got[2]["role"] != "assistant" || got[2]["content"] != "stream answer" || got[2]["id"] != "msg-1" { t.Fatalf("stored assistant message=%#v", got[2]) } } func TestStructureAnswerWithConv_ParsesArrayMessages(t *testing.T) { session := &entity.ChatSession{ ID: "session-1", Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`), } svc := &ChatSessionService{} ans := svc.structureAnswerWithConv(session, map[string]interface{}{ "answer": "Final answer", "reference": map[string]interface{}{"chunks": []interface{}{}}, "final": true, }, "msg-1", "session-1", []interface{}{map[string]interface{}{"chunks": []interface{}{}, "doc_aggs": []interface{}{}}}) if ans["id"] != "msg-1" || ans["session_id"] != "session-1" { t.Fatalf("ans=%#v", ans) } got := parseMessages(session.Message) if len(got) != 1 { t.Fatalf("stored messages=%#v", got) } if got[0]["role"] != "assistant" || got[0]["content"] != "Final answer" || got[0]["id"] != "msg-1" { t.Fatalf("stored assistant message=%#v", got[0]) } } func TestParseMessages_LegacyWrappedObject(t *testing.T) { got := parseMessages(json.RawMessage(`{"messages":[{"role":"assistant","content":"legacy"}]}`)) if !reflect.DeepEqual(got, []map[string]interface{}{{"role": "assistant", "content": "legacy"}}) { t.Fatalf("messages=%#v", got) } } func TestCompletionStream_EmptyMessages(t *testing.T) { svc := &ChatSessionService{ chatSessionDAO: &fakeSessionStore{}, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } streamChan := make(chan string, 10) err := svc.CompletionStream(context.Background(), "user-1", "session-1", nil, "", nil, "msg-1", streamChan) if err == nil || err.Error() != "messages cannot be empty" { t.Fatalf("expected 'messages cannot be empty', got %v", err) } } func TestCompletionStream_LastMessageNotFromUser(t *testing.T) { svc := &ChatSessionService{ chatSessionDAO: &fakeSessionStore{}, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } streamChan := make(chan string, 10) err := svc.CompletionStream(context.Background(), "user-1", "session-1", []map[string]interface{}{ {"role": "assistant", "content": "hello"}, }, "", nil, "msg-1", streamChan) if err == nil || !strings.Contains(err.Error(), "not from user") { t.Fatalf("expected 'not from user' error, got %v", err) } } func TestCompletionStream_ConversationNotFound(t *testing.T) { store := newFakeSessionStore() svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } streamChan := make(chan string, 10) err := svc.CompletionStream(context.Background(), "user-1", "missing", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1", streamChan) if err == nil || err.Error() != "Conversation not found" { t.Fatalf("expected 'Conversation not found', got %v", err) } } func TestCompletionStream_DialogNotFound(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Message: json.RawMessage(`[]`), Reference: json.RawMessage(`[]`), } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{}, } streamChan := make(chan string, 10) err := svc.CompletionStream(context.Background(), "user-1", "session-1", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1", streamChan) if err == nil || err.Error() != "Dialog not found" { t.Fatalf("expected 'Dialog not found', got %v", err) } } func TestCompletionStream_PipelineError(t *testing.T) { store := newFakeSessionStore() store.sessions["session-1"] = &entity.ChatSession{ ID: "session-1", DialogID: "dialog-1", Message: json.RawMessage(`[]`), Reference: json.RawMessage(`[]`), } store.dialogs["dialog-1"] = &entity.Chat{ ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory", LLMSetting: entity.JSONMap{}, } svc := &ChatSessionService{ chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: &fakePipeline{err: errors.New("model unavailable")}, } streamChan := make(chan string, 10) err := svc.CompletionStream(context.Background(), "user-1", "session-1", []map[string]interface{}{ {"role": "user", "content": "hi"}, }, "", nil, "msg-1", streamChan) if err == nil || err.Error() != "model unavailable" { t.Fatalf("expected 'model unavailable' error, got %v", err) } }