test: 编写 TTS 测试(mock HTTP,覆盖成功/API 错误/超时/空文本/取消/部分失败/自定义 voice)
This commit is contained in:
300
backend/internal/ai/tts/openai_test.go
Normal file
300
backend/internal/ai/tts/openai_test.go
Normal file
@@ -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 {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user