Files
CamTalk/backend/internal/ai/tts/openai_test.go

301 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 次 API3 个句子)
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 {
}
}