diff --git a/backend/internal/ai/tts/openai_test.go b/backend/internal/ai/tts/openai_test.go new file mode 100644 index 0000000..3f5e609 --- /dev/null +++ b/backend/internal/ai/tts/openai_test.go @@ -0,0 +1,300 @@ +package tts + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" +) + +// mockTTSServer 创建模拟 OpenAI TTS API 的 HTTP 服务器。 +func mockTTSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +// sendSentences 向 channel 发送句子并关闭。 +func sendSentences(sentences ...string) <-chan string { + ch := make(chan string, len(sentences)) + for _, s := range sentences { + ch <- s + } + close(ch) + return ch +} + +func TestOpenAIService_SynthesizeStream_Success(t *testing.T) { + var callCount int32 + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if !strings.Contains(r.URL.Path, "/audio/speech") { + t.Errorf("path = %s, should contain /audio/speech", r.URL.Path) + } + auth := r.Header.Get("Authorization") + if auth != "Bearer test-key" { + t.Errorf("Authorization = %q, want %q", auth, "Bearer test-key") + } + + // 验证请求体 + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "tts-1") { + t.Errorf("request body should contain model tts-1") + } + + // 返回假 MP3 数据 + w.Header().Set("Content-Type", "audio/mpeg") + fmt.Fprintf(w, "fake-mp3-data") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + + textStream := sendSentences("你好", "世界", "!") + + ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{ + Voice: "alloy", Speed: 1.0, OutputFmt: "mp3", SampleRate: 24000, + }) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + var chunks []Chunk + for c := range ch { + chunks = append(chunks, c) + } + + // 应该有 3 个音频 chunk + 1 个 IsLast 标记 + if len(chunks) != 4 { + t.Fatalf("got %d chunks, want 4", len(chunks)) + } + + // 验证前 3 个有音频数据 + for i := 0; i < 3; i++ { + if string(chunks[i].Audio) != "fake-mp3-data" { + t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data") + } + if chunks[i].IsLast { + t.Errorf("chunk[%d].IsLast should be false", i) + } + } + + // 验证最后一个是 IsLast + if !chunks[3].IsLast { + t.Error("last chunk should be IsLast") + } + if chunks[3].Audio != nil { + t.Error("last chunk Audio should be nil") + } + + // 验证调用了 3 次 API(3 个句子) + if atomic.LoadInt32(&callCount) != 3 { + t.Errorf("API called %d times, want 3", callCount) + } +} + +func TestOpenAIService_SynthesizeStream_APIError(t *testing.T) { + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "internal error") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + + textStream := sendSentences("你好") + + ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + // 应该只有一个 IsLast chunk(音频被跳过) + var chunks []Chunk + for c := range ch { + chunks = append(chunks, c) + } + + if len(chunks) != 1 { + t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks)) + } + if !chunks[0].IsLast { + t.Error("chunk should be IsLast") + } +} + +func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) { + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + w.Header().Set("Content-Type", "audio/mpeg") + fmt.Fprintf(w, "late-mp3") + }) + defer srv.Close() + + // 1 秒超时 + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 1, zap.NewNop().Sugar()) + + textStream := sendSentences("很长的句子") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch, err := svc.SynthesizeStream(ctx, textStream, Options{}) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + var chunks []Chunk + for c := range ch { + chunks = append(chunks, c) + } + + // 超时后音频被跳过,只有 IsLast + if len(chunks) != 1 { + t.Fatalf("got %d chunks, want 1", len(chunks)) + } + if !chunks[0].IsLast { + t.Error("chunk should be IsLast") + } +} + +func TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) { + var callCount int32 + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.Header().Set("Content-Type", "audio/mpeg") + fmt.Fprintf(w, "mp3") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + + // 空句子应该被跳过 + textStream := sendSentences("", "你好", "") + + ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + var chunks []Chunk + for c := range ch { + chunks = append(chunks, c) + } + + // 只有 "你好" 应该被合成 + if atomic.LoadInt32(&callCount) != 1 { + t.Errorf("API called %d times, want 1", callCount) + } + + // 1 个音频 + 1 个 IsLast + if len(chunks) != 2 { + t.Fatalf("got %d chunks, want 2", len(chunks)) + } +} + +func TestOpenAIService_SynthesizeStream_ContextCancelled(t *testing.T) { + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/mpeg") + fmt.Fprintf(w, "mp3") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + + // 发送多个句子,但在第一个后取消 + textStream := make(chan string, 3) + textStream <- "第一句" + textStream <- "第二句" + textStream <- "第三句" + close(textStream) + + ctx, cancel := context.WithCancel(context.Background()) + // 立即取消 + cancel() + + ch, err := svc.SynthesizeStream(ctx, textStream, Options{}) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + // 消费 channel,应该很快结束 + var count int + for range ch { + count++ + } + // 可能收到 0 个或 1 个 chunk,取决于时序 + t.Logf("received %d chunks after context cancel", count) +} + +func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) { + var callCount int32 + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + if n == 2 { + // 第二个句子失败 + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "error") + return + } + w.Header().Set("Content-Type", "audio/mpeg") + fmt.Fprintf(w, "mp3-%d", n) + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + + textStream := sendSentences("第一句", "第二句", "第三句") + + ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + var chunks []Chunk + for c := range ch { + chunks = append(chunks, c) + } + + // 2 个成功音频 + 1 个 IsLast(第二句被跳过) + if len(chunks) != 3 { + t.Fatalf("got %d chunks, want 3", len(chunks)) + } + if !chunks[len(chunks)-1].IsLast { + t.Error("last chunk should be IsLast") + } +} + +func TestOpenAIService_SynthesizeStream_CustomVoice(t *testing.T) { + srv := mockTTSServer(t, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "nova") { + t.Errorf("request body should contain voice 'nova', got: %s", string(body)) + } + w.Header().Set("Content-Type", "audio/mpeg") + fmt.Fprintf(w, "mp3") + }) + defer srv.Close() + + svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + + textStream := sendSentences("你好") + + ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{Voice: "nova"}) + if err != nil { + t.Fatalf("SynthesizeStream() error: %v", err) + } + + for range ch { + } +}