Files
gochat/backend/internal/service/captain_task_service_streaming_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

285 lines
9.5 KiB
Go

package service
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gochat/gochat/internal/llm"
)
// ========== Streaming Tests (M12) ==========
func TestCaptainTaskService_ReplySuggestionStream_成功(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.streamChunks = []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "建议一"}, FinishReason: ""}}},
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "建议二"}, FinishReason: "stop"}}},
}
var collected []string
err := svc.ReplySuggestionStream(context.Background(), account.ID, &TaskReplySuggestionRequest{
AssistantID: assistant.ID,
ConversationID: conv.ID,
Tone: "friendly",
}, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
collected = append(collected, chunk.Choices[0].Delta.Content)
}
return nil
})
require.NoError(t, err)
assert.Len(t, collected, 2)
assert.Equal(t, "建议一", collected[0])
assert.Equal(t, "建议二", collected[1])
}
func TestCaptainTaskService_ReplySuggestionStream_助手不存在(t *testing.T) {
_, _, svc := setupCaptainTaskService(t)
err := svc.ReplySuggestionStream(context.Background(), 1, &TaskReplySuggestionRequest{
AssistantID: 99999,
ConversationID: 1,
}, func(chunk llm.StreamChunk) error {
return nil
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "assistant not found")
}
func TestCaptainTaskService_ReplySuggestionStream_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.streamError = fmt.Errorf("LLM stream 调用超时")
err := svc.ReplySuggestionStream(context.Background(), account.ID, &TaskReplySuggestionRequest{
AssistantID: assistant.ID,
ConversationID: conv.ID,
}, func(chunk llm.StreamChunk) error {
return nil
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "LLM stream")
}
func TestCaptainTaskService_SummarizeStream_成功(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
{"contact", "你好,我的订单还没收到"},
{"agent", "我来帮您查询一下"},
})
mockProvider.streamChunks = []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "客户咨询订单"}, FinishReason: ""}}},
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "状态查询"}, FinishReason: "stop"}}},
}
var collected []string
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
Language: "zh-CN",
}, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
collected = append(collected, chunk.Choices[0].Delta.Content)
}
return nil
})
require.NoError(t, err)
assert.Len(t, collected, 2)
}
func TestCaptainTaskService_SummarizeStream_无消息(t *testing.T) {
db, _, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, nil)
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
}, func(chunk llm.StreamChunk) error {
return nil
})
assert.Error(t, err)
status, message, ok := CaptainTaskErrorStatus(err)
require.True(t, ok)
assert.Equal(t, 422, status)
assert.Contains(t, message, "no messages found")
}
func TestCaptainTaskService_SummarizeStream_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.streamError = fmt.Errorf("LLM stream 服务不可用")
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
}, func(chunk llm.StreamChunk) error {
return nil
})
assert.Error(t, err)
}
func TestCaptainTaskService_RewriteStream_成功(t *testing.T) {
_, mockProvider, svc := setupCaptainTaskService(t)
mockProvider.streamChunks = []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "感谢您的"}, FinishReason: ""}}},
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "来信"}, FinishReason: "stop"}}},
}
var collected []string
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{
Content: "来信收到了",
Tone: "professional",
Language: "zh-CN",
}, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
collected = append(collected, chunk.Choices[0].Delta.Content)
}
return nil
})
require.NoError(t, err)
assert.Len(t, collected, 2)
assert.Equal(t, "感谢您的", collected[0])
}
func TestCaptainTaskService_RewriteStream_默认参数(t *testing.T) {
_, mockProvider, svc := setupCaptainTaskService(t)
mockProvider.streamChunks = []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "重写文本"}, FinishReason: "stop"}}},
}
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{
Content: "原始内容",
}, func(chunk llm.StreamChunk) error {
return nil
})
require.NoError(t, err)
require.NotNil(t, mockProvider.lastStreamReq)
// 默认 tone 为 professional, language 为 zh-CN
assert.Contains(t, mockProvider.lastStreamReq.Messages[0].Content, "zh-CN")
}
func TestCaptainTaskService_RewriteStream_LLM流错误(t *testing.T) {
_, mockProvider, svc := setupCaptainTaskService(t)
mockProvider.streamError = fmt.Errorf("LLM stream 不可用")
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{
Content: "test",
}, func(chunk llm.StreamChunk) error {
return nil
})
assert.Error(t, err)
}
func TestCaptainTaskService_Stream_NoProviderDisabled(t *testing.T) {
_, _, svc := setupCaptainTaskService(t)
svc.llmProvider = nil
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{Content: "test", Operation: "professional"}, func(chunk llm.StreamChunk) error {
return nil
})
status, message, ok := CaptainTaskErrorStatus(err)
require.True(t, ok)
assert.Equal(t, 422, status)
assert.Equal(t, "Captain is disabled", message)
}
func TestCaptainTaskService_RewriteStream_InvalidOperation(t *testing.T) {
_, _, svc := setupCaptainTaskService(t)
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{Content: "test", Operation: "pirate"}, func(chunk llm.StreamChunk) error {
return nil
})
status, message, ok := CaptainTaskErrorStatus(err)
require.True(t, ok)
assert.Equal(t, 422, status)
assert.Equal(t, "Invalid operation: pirate", message)
}
func TestCaptainTaskService_SummarizeStream_ChatwootDisplayID(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{{"contact", "hello"}})
displayID := uint(321)
require.NoError(t, db.Model(conv).Update("display_id", displayID).Error)
mockProvider.streamChunks = []llm.StreamChunk{{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "summary"}, FinishReason: "stop"}}}}
var collected []string
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{ConversationDisplayID: displayID}, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
collected = append(collected, chunk.Choices[0].Delta.Content)
}
return nil
})
require.NoError(t, err)
assert.Equal(t, []string{"summary"}, collected)
}
func TestCaptainTaskService_ReplySuggestionStream_回调中断(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.streamChunks = []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "chunk1"}, FinishReason: ""}}},
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "chunk2"}, FinishReason: ""}}},
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "chunk3"}, FinishReason: "stop"}}},
}
callCount := 0
err := svc.ReplySuggestionStream(context.Background(), account.ID, &TaskReplySuggestionRequest{
AssistantID: assistant.ID,
ConversationID: conv.ID,
}, func(chunk llm.StreamChunk) error {
callCount++
if callCount == 2 {
return fmt.Errorf("callback interrupted")
}
return nil
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "callback interrupted")
assert.Equal(t, 2, callCount)
}