From 90f4b907a78d7a2585ff5f43a0b638c0e4cf3df6 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 15:45:51 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E7=BC=96=E5=86=99=20LLM=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=88mock=20SSE=20=E6=B5=81=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=88=90=E5=8A=9F/=E5=9B=BE=E7=89=87/=E5=8E=86?= =?UTF-8?q?=E5=8F=B2/API=20=E9=94=99=E8=AF=AF/=E8=B6=85=E6=97=B6/Usage?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/ai/llm/openai_test.go | 251 +++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 backend/internal/ai/llm/openai_test.go diff --git a/backend/internal/ai/llm/openai_test.go b/backend/internal/ai/llm/openai_test.go new file mode 100644 index 0000000..9f2eee2 --- /dev/null +++ b/backend/internal/ai/llm/openai_test.go @@ -0,0 +1,251 @@ +package llm + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/hhs/camtalk/internal/models" +) + +// mockLLMServer 创建模拟 OpenAI SSE 流式响应的 HTTP 服务器。 +func mockLLMServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func TestOpenAIService_ChatStream_Success(t *testing.T) { + srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) { + // 验证请求 + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if !strings.Contains(r.URL.Path, "/chat/completions") { + t.Errorf("path = %s, should contain /chat/completions", r.URL.Path) + } + auth := r.Header.Get("Authorization") + if auth != "Bearer test-key" { + t.Errorf("Authorization = %q, want %q", auth, "Bearer test-key") + } + + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("ResponseWriter does not support Flusher") + } + + // 发送几个 delta + deltas := []string{"你好", "世界", "!"} + for _, d := range deltas { + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"%s\"}}],\"model\":\"gpt-4o\"}\n\n", d) + flusher.Flush() + } + + // 发送 [DONE] + fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + + ch, err := svc.ChatStream(context.Background(), Request{ + Text: "这是什么?", + Language: "zh-CN", + }) + if err != nil { + t.Fatalf("ChatStream() error: %v", err) + } + + var chunks []Chunk + for c := range ch { + chunks = append(chunks, c) + } + + // 应该有 3 个文本 chunk + 1 个 Done chunk + if len(chunks) != 4 { + t.Fatalf("got %d chunks, want 4", len(chunks)) + } + + // 验证文本内容 + if chunks[0].Delta != "你好" { + t.Errorf("chunk[0].Delta = %q, want %q", chunks[0].Delta, "你好") + } + if chunks[1].Delta != "世界" { + t.Errorf("chunk[1].Delta = %q, want %q", chunks[1].Delta, "世界") + } + + // 验证最后一个 chunk 是 Done + last := chunks[len(chunks)-1] + if !last.Done { + t.Error("last chunk should be Done") + } + if last.Model != "gpt-4o" { + t.Errorf("last chunk Model = %q, want %q", last.Model, "gpt-4o") + } +} + +func TestOpenAIService_ChatStream_WithImage(t *testing.T) { + srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}],\"model\":\"gpt-4o\"}\n\n") + fmt.Fprintf(w, "data: [DONE]\n\n") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + + ch, err := svc.ChatStream(context.Background(), Request{ + Image: []byte("fake-jpeg-data"), + Text: "描述图片", + Language: "zh-CN", + }) + if err != nil { + t.Fatalf("ChatStream() error: %v", err) + } + + // 消费 channel + for range ch { + } +} + +func TestOpenAIService_ChatStream_WithHistory(t *testing.T) { + srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}],\"model\":\"gpt-4o\"}\n\n") + fmt.Fprintf(w, "data: [DONE]\n\n") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + + ch, err := svc.ChatStream(context.Background(), Request{ + Text: "继续", + Language: "zh-CN", + History: []models.Message{ + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!有什么可以帮助你的吗?"}, + }, + }) + if err != nil { + t.Fatalf("ChatStream() error: %v", err) + } + + for range ch { + } +} + +func TestOpenAIService_ChatStream_APIError(t *testing.T) { + srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":{"message":"Invalid API key"}}`) + }) + defer srv.Close() + + svc := NewOpenAIService("bad-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + + _, err := svc.ChatStream(context.Background(), Request{ + Text: "test", + }) + if err == nil { + t.Fatal("ChatStream() should return error for 401") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("error should mention 401, got: %v", err) + } +} + +func TestOpenAIService_ChatStream_Timeout(t *testing.T) { + srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) { + // 模拟慢响应 + time.Sleep(5 * time.Second) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n") + fmt.Fprintf(w, "data: [DONE]\n\n") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 1, zap.NewNop().Sugar()) // 1s timeout + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ch, err := svc.ChatStream(ctx, Request{Text: "test"}) + if err != nil { + // 超时可能在建立连接时或读取时发生 + return + } + + // 如果连接成功,消费 channel 应该超时 + var gotContent bool + for c := range ch { + if c.Delta != "" { + gotContent = true + } + } + if gotContent { + t.Error("should not receive content before timeout") + } +} + +func TestOpenAIService_ChatStream_UsageInResponse(t *testing.T) { + srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + // 带 usage 的最后一个 chunk + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\"model\":\"gpt-4o\",\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n") + fmt.Fprintf(w, "data: [DONE]\n\n") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + + ch, err := svc.ChatStream(context.Background(), Request{Text: "test"}) + if err != nil { + t.Fatalf("ChatStream() error: %v", err) + } + + var last Chunk + for c := range ch { + last = c + } + + if !last.Done { + t.Error("last chunk should be Done") + } + if last.TokensUsed == nil { + t.Fatal("last chunk should have TokensUsed") + } + if last.TokensUsed.Total != 15 { + t.Errorf("TokensUsed.Total = %d, want 15", last.TokensUsed.Total) + } +} + +func TestBuildSystemPrompt(t *testing.T) { + tests := []struct { + name string + language string + detailLevel string + wantContain string + }{ + {"chinese default", "zh-CN", "", "视觉助手"}, + {"chinese high", "zh-CN", "high", "更详细"}, + {"english default", "en", "", "visual assistant"}, + {"english high", "en", "high", "detailed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildSystemPrompt(tt.language, tt.detailLevel) + if !strings.Contains(got, tt.wantContain) { + t.Errorf("BuildSystemPrompt(%q, %q) should contain %q", tt.language, tt.detailLevel, tt.wantContain) + } + }) + } +}