package service import ( "context" "encoding/json" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // ========== Mock LLM Provider ========== // mockLLMProvider is a mock implementation of llm.Provider for testing. type mockLLMProvider struct { chatResponse *llm.ChatResponse chatError error embeddingResponse *llm.EmbeddingResponse embeddingError error lastChatRequest *llm.ChatRequest lastEmbeddingReq *llm.EmbeddingRequest streamChunks []llm.StreamChunk streamError error lastStreamReq *llm.ChatRequest } func (m *mockLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { m.lastChatRequest = &req return m.chatResponse, m.chatError } func (m *mockLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { m.lastEmbeddingReq = &req return m.embeddingResponse, m.embeddingError } func (m *mockLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error { m.lastStreamReq = &req if m.streamError != nil { return m.streamError } for _, chunk := range m.streamChunks { if err := onChunk(chunk); err != nil { return err } } return nil } // ========== Test Setup ========== // setupCaptainTaskService 创建测试所需的数据库、mockLLMProvider 和 CaptainTaskService 实例。 func setupCaptainTaskService(t *testing.T) (*gorm.DB, *mockLLMProvider, *CaptainTaskService) { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("无法打开 SQLite 测试数据库: %v", err) } // 自动迁移所需模型 if err := db.AutoMigrate( &model.Account{}, &model.Conversation{}, &model.Message{}, &model.CaptainAssistant{}, &model.CaptainAssistantResponse{}, &model.CaptainCustomTool{}, &model.CaptainDocument{}, ); err != nil { t.Fatalf("无法自动迁移模型: %v", err) } t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) mockProvider := &mockLLMProvider{} assistantRepo := repository.NewCaptainAssistantRepo(db) responseRepo := repository.NewCaptainAssistantResponseRepo(db) customToolRepo := repository.NewCaptainCustomToolRepo(db) conversationRepo := repository.NewConversationRepo(db) messageRepo := repository.NewMessageRepo(db) contactRepo := repository.NewContactRepo(db) // M12: CopilotContextService for context enrichment in streaming copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockProvider) svc := NewCaptainTaskService(assistantRepo, responseRepo, customToolRepo, conversationRepo, messageRepo, mockProvider, copilotContextSvc) return db, mockProvider, svc } // createTestAssistant 创建一个测试 CaptainAssistant。 func createTestAssistant(t *testing.T, db *gorm.DB, accountID uint, name string) *model.CaptainAssistant { t.Helper() assistant := &model.CaptainAssistant{ AccountID: accountID, Name: name, Status: model.AssistantStatusActive, Config: marshalAssistantConfig(model.DefaultAssistantConfig()), } if err := db.Create(assistant).Error; err != nil { t.Fatalf("无法创建测试助手: %v", err) } return assistant } // createTestConversationWithMessages 创建一个测试会话并添加消息。 func createTestConversationWithMessages(t *testing.T, db *gorm.DB, accountID uint, msgs []struct{ sender, content string }) *model.Conversation { t.Helper() conv := &model.Conversation{ AccountID: accountID, Status: "open", } if err := db.Create(conv).Error; err != nil { t.Fatalf("无法创建测试会话: %v", err) } for _, m := range msgs { senderType := "contact" msgType := "incoming" if m.sender == "agent" { senderType = "agent" msgType = "outgoing" } msg := &model.Message{ ConversationID: conv.ID, AccountID: accountID, Content: m.content, SenderType: senderType, MessageType: msgType, } if err := db.Create(msg).Error; err != nil { t.Fatalf("无法创建测试消息: %v", err) } } return conv } // makeChatResponse 创建一个简单的 LLM ChatResponse。 func makeChatResponse(content string) *llm.ChatResponse { return &llm.ChatResponse{ Choices: []llm.ChatChoice{ { Message: llm.ChatMessage{ Role: "assistant", Content: content, }, }, }, } } // makeEmbeddingResponse 创建一个模拟的 EmbeddingResponse。 func makeEmbeddingResponse() *llm.EmbeddingResponse { // 8维向量,足以通过 SQLite 测试 dims := make([]float64, 8) return &llm.EmbeddingResponse{ Data: []llm.EmbeddingData{ { Embedding: dims, }, }, } } // ========== Summarize Tests ========== func TestCaptainTaskService_Summarize_成功(t *testing.T) { db, mockProvider, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{ {"contact", "你好,我的订单还没收到"}, {"agent", "我来帮您查询一下"}, {"contact", "订单号是 12345"}, }) mockProvider.chatResponse = makeChatResponse("客户咨询订单状态,订单号12345,代理正在查询") result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{ ConversationID: conv.ID, Language: "zh-CN", }) require.NoError(t, err) assert.NotEmpty(t, result.Summary) } func TestCaptainTaskService_Summarize_无消息(t *testing.T) { db, _, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) conv := createTestConversationWithMessages(t, db, account.ID, nil) result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "no messages") } func TestCaptainTaskService_Summarize_LLM失败(t *testing.T) { db, mockProvider, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{ {"contact", "test message"}, }) mockProvider.chatError = fmt.Errorf("LLM API 调用失败") result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "llm") } func TestCaptainTaskService_Summarize_默认语言(t *testing.T) { db, mockProvider, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{ {"contact", "hello"}, }) mockProvider.chatResponse = makeChatResponse("摘要内容") _, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{ ConversationID: conv.ID, }) require.NoError(t, err) require.NotNil(t, mockProvider.lastChatRequest) // 默认语言 zh-CN 应出现在 system prompt 中 assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "zh-CN") } // ========== Rewrite Tests ========== func TestCaptainTaskService_Rewrite_成功(t *testing.T) { _, mockProvider, svc := setupCaptainTaskService(t) mockProvider.chatResponse = makeChatResponse("感谢您的来信,我会尽快处理。") result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{ Content: "来信收到了", Tone: "professional", Language: "zh-CN", }) require.NoError(t, err) assert.NotEmpty(t, result.RewrittenContent) assert.Equal(t, "professional", result.Tone) } func TestCaptainTaskService_Rewrite_默认参数(t *testing.T) { _, mockProvider, svc := setupCaptainTaskService(t) mockProvider.chatResponse = makeChatResponse("rewritten text") result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{ Content: "原始内容", }) require.NoError(t, err) assert.Equal(t, "professional", result.Tone) // 默认 professional require.NotNil(t, mockProvider.lastChatRequest) assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "zh-CN") } func TestCaptainTaskService_Rewrite_带助手风格(t *testing.T) { db, mockProvider, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) assistant := createTestAssistant(t, db, account.ID, "客服助手") // 更新助手 config 添加 instructions cfg := &model.AssistantConfig{ Temperature: 0.7, Instructions: "始终以礼貌的语气回复", } configJSON, _ := json.Marshal(cfg) db.Model(assistant).Update("config", configJSON) mockProvider.chatResponse = makeChatResponse("礼貌回复内容") result, err := svc.Rewrite(context.Background(), account.ID, &TaskRewriteRequest{ Content: "好的知道了", Tone: "friendly", Language: "zh-CN", AssistantID: assistant.ID, }) require.NoError(t, err) assert.NotEmpty(t, result.RewrittenContent) require.NotNil(t, mockProvider.lastChatRequest) assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "礼貌") } func TestCaptainTaskService_Rewrite_LLM失败(t *testing.T) { _, mockProvider, svc := setupCaptainTaskService(t) mockProvider.chatError = fmt.Errorf("LLM 服务不可用") result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{ Content: "test", }) assert.Error(t, err) assert.Nil(t, result) } // ========== ReplySuggestion Tests ========== func TestCaptainTaskService_ReplySuggestion_成功(t *testing.T) { db, mockProvider, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) assistant := createTestAssistant(t, db, account.ID, "客服助手") conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{ {"contact", "你们的产品怎么使用?"}, {"agent", "请问是哪个产品?"}, }) mockProvider.embeddingResponse = makeEmbeddingResponse() mockProvider.chatResponse = makeChatResponse("1. 您可以参考产品手册\n2. 请访问我们的帮助中心\n3. 我来为您演示使用方法") result, err := svc.ReplySuggestion(context.Background(), account.ID, &TaskReplySuggestionRequest{ AssistantID: assistant.ID, ConversationID: conv.ID, Tone: "friendly", }) require.NoError(t, err) assert.NotEmpty(t, result.Suggestions) assert.Equal(t, "llm", result.Source) // no RAG docs in SQLite, so source is "llm" } func TestCaptainTaskService_ReplySuggestion_助手不存在(t *testing.T) { _, _, svc := setupCaptainTaskService(t) result, err := svc.ReplySuggestion(context.Background(), 1, &TaskReplySuggestionRequest{ AssistantID: 99999, ConversationID: 1, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "assistant not found") } func TestCaptainTaskService_ReplySuggestion_LLM失败(t *testing.T) { db, mockProvider, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) assistant := createTestAssistant(t, db, account.ID, "助手") conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{ {"contact", "hello"}, }) mockProvider.embeddingResponse = makeEmbeddingResponse() mockProvider.chatError = fmt.Errorf("LLM 调用超时") result, err := svc.ReplySuggestion(context.Background(), account.ID, &TaskReplySuggestionRequest{ AssistantID: assistant.ID, ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) } // ========== parseSuggestions Tests ========== func TestParseSuggestions_编号列表(t *testing.T) { content := "1. 建议一\n2. 建议二\n3. 建议三" result := parseSuggestions(content) assert.Len(t, result, 3) assert.Equal(t, "建议一", result[0]) assert.Equal(t, "建议二", result[1]) } func TestParseSuggestions_子弹列表(t *testing.T) { content := "- 建议A\n* 建议B\n- 建议C" result := parseSuggestions(content) assert.Len(t, result, 3) assert.Equal(t, "建议A", result[0]) } func TestParseSuggestions_逗号分隔(t *testing.T) { content := "建议X,建议Y,建议Z" result := parseSuggestions(content) assert.Len(t, result, 3) } func TestParseSuggestions_单条文本(t *testing.T) { content := "这是一个建议" result := parseSuggestions(content) assert.Len(t, result, 1) assert.Equal(t, "这是一个建议", result[0]) } func TestParseSuggestions_空内容(t *testing.T) { content := "" result := parseSuggestions(content) assert.Len(t, result, 1) }