package services import ( "context" "errors" "strings" "testing" "time" "agent-desk/internal/ai" "agent-desk/internal/models" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/i18nx" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/schema" ) func setupAICopilotTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: "t_", SingularTable: true, }, }) if err != nil { t.Fatalf("open sqlite error = %v", err) } t.Cleanup(func() { sqlDB, err := db.DB() if err == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate(&models.Conversation{}, &models.Message{}); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) return db } func TestAICopilotService_BuildConversationTranscript(t *testing.T) { recalledTime := time.Now() messages := []models.Message{ { ID: 1, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "Hello, I need help with my account", }, { ID: 2, SenderType: enums.IMSenderTypeAgent, MessageType: enums.IMMessageTypeText, Content: "Hi there! What issue are you experiencing?", }, { ID: 3, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeHTML, Content: "

I see an error code 500 on checkout.

", }, { ID: 4, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeImage, Content: "screenshot.png", }, { ID: 5, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeAttachment, Content: "log.txt", }, { ID: 6, SenderType: enums.IMSenderTypeAgent, MessageType: enums.IMMessageTypeText, Content: "This was recalled", RecalledAt: &recalledTime, SendStatus: enums.IMMessageStatusRecalled, }, { ID: 7, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: " ", // empty content should be ignored }, { ID: 8, SenderType: enums.IMSenderTypeAI, MessageType: enums.IMMessageTypeText, Content: "Our system is checking your account now.", }, } svc := newAICopilotService() transcript := svc.buildConversationTranscript(messages) expectedLines := []string{ "Customer: Hello, I need help with my account", "Agent: Hi there! What issue are you experiencing?", "Customer: I see an error code 500 on checkout.", "Customer: [Image: screenshot.png]", "Customer: [Attachment: log.txt]", "Agent: Our system is checking your account now.", } for _, line := range expectedLines { if !strings.Contains(transcript, line) { t.Errorf("transcript missing expected line: %q\nFull transcript:\n%s", line, transcript) } } if strings.Contains(transcript, "This was recalled") { t.Errorf("recalled message should not appear in transcript: %s", transcript) } } func TestAICopilotService_BuildSuggestReplyPrompts_Locales(t *testing.T) { svc := newAICopilotService() transcript := "Customer: How do I change password?" locales := []struct { locale string wantSystemContent string wantUserPrefix string }{ { locale: i18nx.LocaleEnUS, wantSystemContent: "expert customer support co-pilot", wantUserPrefix: "Conversation history:", }, { locale: i18nx.LocaleZhCN, wantSystemContent: "智能客服助手", wantUserPrefix: "对话记录:", }, { locale: i18nx.LocaleRuRU, wantSystemContent: "ассистент оператора службы поддержки", wantUserPrefix: "История диалога:", }, } for _, tc := range locales { t.Run(tc.locale, func(t *testing.T) { sysPrompt, usrPrompt := svc.buildSuggestReplyPrompts(transcript, tc.locale) if !strings.Contains(sysPrompt, tc.wantSystemContent) { t.Errorf("sysPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantSystemContent, sysPrompt) } if !strings.Contains(usrPrompt, tc.wantUserPrefix) { t.Errorf("usrPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantUserPrefix, usrPrompt) } if !strings.Contains(usrPrompt, "\n"+transcript+"\n") { t.Errorf("usrPrompt for locale %s did not contain tagged transcript, got: %s", tc.locale, usrPrompt) } }) } } func TestAICopilotService_BuildSummarizePrompts_Locales(t *testing.T) { svc := newAICopilotService() transcript := "Customer: My account is locked.\nAgent: Let me unlock it." locales := []struct { locale string wantSystemContent string wantUserPrefix string }{ { locale: i18nx.LocaleEnUS, wantSystemContent: "customer support summarizer", wantUserPrefix: "Conversation history:", }, { locale: i18nx.LocaleZhCN, wantSystemContent: "客服对话总结助手", wantUserPrefix: "对话记录:", }, { locale: i18nx.LocaleRuRU, wantSystemContent: "ассистент по подведению итогов", wantUserPrefix: "История диалога:", }, } for _, tc := range locales { t.Run(tc.locale, func(t *testing.T) { sysPrompt, usrPrompt := svc.buildSummarizePrompts(transcript, tc.locale) if !strings.Contains(sysPrompt, tc.wantSystemContent) { t.Errorf("sysPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantSystemContent, sysPrompt) } if !strings.Contains(usrPrompt, tc.wantUserPrefix) { t.Errorf("usrPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantUserPrefix, usrPrompt) } if !strings.Contains(usrPrompt, "\n"+transcript+"\n") { t.Errorf("usrPrompt for locale %s did not contain tagged transcript, got: %s", tc.locale, usrPrompt) } }) } } func TestAICopilotService_ExtractMessageText_RuneLimit(t *testing.T) { svc := newAICopilotService() longText := strings.Repeat("a", 2500) msg := models.Message{ SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: longText, } extracted := svc.extractMessageText(msg) if len([]rune(extracted)) != 2000 { t.Fatalf("expected extracted length to be 2000 runes, got %d", len([]rune(extracted))) } } func TestAICopilotService_SuggestReply_ValidationAndErrors(t *testing.T) { db := setupAICopilotTestDB(t) ctx := context.Background() svc := newAICopilotService() // 1. Invalid conversation ID <= 0 _, err := svc.SuggestReply(ctx, 0, "en-US") if err == nil { t.Fatal("expected error for conversationID <= 0, got nil") } // 2. Conversation not found _, err = svc.SuggestReply(ctx, 99999, "en-US") if err == nil { t.Fatal("expected error for non-existent conversation, got nil") } if i18nErr, ok := err.(*errorsx.I18nError); !ok || i18nErr.Key != "error.e0116" { t.Fatalf("expected error.e0116 for not found conversation, got: %v", err) } // 3. Conversation exists but has no messages conv := &models.Conversation{ ID: 101, Subject: "Empty conversation", } if err := db.Create(conv).Error; err != nil { t.Fatalf("failed to create conv: %v", err) } _, err = svc.SuggestReply(ctx, conv.ID, "en-US") if err == nil { t.Fatal("expected error when conversation has no messages, got nil") } // 4. Conversation has only recalled messages recalledTime := time.Now() msg := &models.Message{ ID: 1, ConversationID: conv.ID, ClientMsgID: "msg_1", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "recalled text", RecalledAt: &recalledTime, SendStatus: enums.IMMessageStatusRecalled, } if err := db.Create(msg).Error; err != nil { t.Fatalf("failed to create msg: %v", err) } _, err = svc.SuggestReply(ctx, conv.ID, "en-US") if err == nil { t.Fatal("expected error when conversation has only recalled messages, got nil") } // 5. LLM chat failure validMsg := &models.Message{ ID: 2, ConversationID: conv.ID, ClientMsgID: "msg_2", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "Can you help me reset my password?", } if err := db.Create(validMsg).Error; err != nil { t.Fatalf("failed to create valid msg: %v", err) } svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) { return nil, errors.New("upstream llm timeout") } _, err = svc.SuggestReply(ctx, conv.ID, "en-US") if err == nil || !strings.Contains(err.Error(), "upstream llm timeout") { t.Fatalf("expected llm error to propagate, got: %v", err) } } func TestAICopilotService_SuggestReply_Success(t *testing.T) { db := setupAICopilotTestDB(t) ctx := context.Background() conv := &models.Conversation{ ID: 201, Subject: "Password Reset", } if err := db.Create(conv).Error; err != nil { t.Fatalf("create conversation failed: %v", err) } msg := &models.Message{ ID: 10, ConversationID: conv.ID, ClientMsgID: "msg_10", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "I forgot my password, can you help?", } if err := db.Create(msg).Error; err != nil { t.Fatalf("create message failed: %v", err) } var capturedSys, capturedUser string svc := newAICopilotService() svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) { capturedSys = systemPrompt capturedUser = userPrompt return &ai.ChatCompletionResult{ Content: "To reset your password, please click the 'Forgot Password' link on the login page.", ModelName: "gpt-4o", }, nil } suggestion, err := svc.SuggestReply(ctx, conv.ID, "en-US") if err != nil { t.Fatalf("SuggestReply failed: %v", err) } expectedSuggestion := "To reset your password, please click the 'Forgot Password' link on the login page." if suggestion != expectedSuggestion { t.Fatalf("expected suggestion %q, got %q", expectedSuggestion, suggestion) } if !strings.Contains(capturedSys, "expert customer support co-pilot") { t.Errorf("captured system prompt missing expected instructions: %s", capturedSys) } if !strings.Contains(capturedUser, "Customer: I forgot my password, can you help?") { t.Errorf("captured user prompt missing message transcript: %s", capturedUser) } } func TestAICopilotService_SummarizeConversation_Success(t *testing.T) { db := setupAICopilotTestDB(t) ctx := context.Background() conv := &models.Conversation{ ID: 301, Subject: "Billing Inquiry", } if err := db.Create(conv).Error; err != nil { t.Fatalf("create conversation failed: %v", err) } messages := []models.Message{ { ID: 30, ConversationID: conv.ID, ClientMsgID: "msg_30", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "Why was I charged twice for subscription?", }, { ID: 31, ConversationID: conv.ID, ClientMsgID: "msg_31", SenderType: enums.IMSenderTypeAgent, MessageType: enums.IMMessageTypeText, Content: "I see the duplicate transaction and have initiated a full refund for the second charge.", }, } for _, m := range messages { if err := db.Create(&m).Error; err != nil { t.Fatalf("create message failed: %v", err) } } var capturedSys, capturedUser string svc := newAICopilotService() svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) { capturedSys = systemPrompt capturedUser = userPrompt return &ai.ChatCompletionResult{ Content: "1. Main Issue: Duplicate subscription charge.\n2. Actions taken: Initiated full refund for duplicate charge.\n3. Current Status: Resolved.", ModelName: "gpt-4o", }, nil } summary, err := svc.SummarizeConversation(ctx, conv.ID, "en-US") if err != nil { t.Fatalf("SummarizeConversation failed: %v", err) } if !strings.Contains(summary, "Duplicate subscription charge") { t.Fatalf("unexpected summary: %s", summary) } if !strings.Contains(capturedSys, "customer support summarizer") { t.Errorf("captured system prompt missing summarizer instructions: %s", capturedSys) } if !strings.Contains(capturedUser, "Customer: Why was I charged twice for subscription?") { t.Errorf("captured user prompt missing transcript: %s", capturedUser) } }