package tts // 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"): // // 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回 // `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按 // UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是 // 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。 // 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。 // 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。 // 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。 // 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。 import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。 const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!" // wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。 func wavBytes(payload string) []byte { b := make([]byte, 0, 44+len(payload)) b = append(b, []byte("RIFF")...) b = append(b, 0, 0, 0, 0) b = append(b, []byte("WAVEfmt ")...) b = append(b, bytes.Repeat([]byte{0}, 16)...) b = append(b, []byte("data")...) b = append(b, 0, 0, 0, 0) return append(b, []byte(payload)...) } // newTestClient 指向一个 httptest 服务;可变选项留给各用例。 func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) base := []Option{WithLogger(discardLogger())} base = append(base, opts...) return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...) } // TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8 // 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。 func TestSpeechSendsUTF8ChineseText(t *testing.T) { var gotPayload speechPayload var gotRaw []byte var gotCT string client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { gotCT = r.Header.Get("Content-Type") gotRaw, _ = io.ReadAll(r.Body) if err := json.Unmarshal(gotRaw, &gotPayload); err != nil { t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err) } w.Header().Set("Content-Type", "audio/wav") _, _ = w.Write(wavBytes("ok")) }) audio, err := client.Speech(context.Background(), chineseSpeech) if err != nil { t.Fatalf("Speech: %v", err) } if gotCT != "application/json" { t.Errorf("Content-Type = %q, want application/json", gotCT) } // ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。 if gotPayload.Input != chineseSpeech { t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech) } // ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码, // 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。 if !bytes.Contains(gotRaw, []byte(chineseSpeech)) { t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw)) } if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) { t.Fatalf("请求体里找不到「你好」的 UTF-8 字节") } if gotPayload.Model != modelName { t.Errorf("model = %q, want %q", gotPayload.Model, modelName) } if gotPayload.Voice != DefaultCharacter { t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter) } if gotPayload.ResponseFormat != DefaultFormat { t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat) } // ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。 if audio.Format != "wav" || len(audio.Data) == 0 { t.Fatalf("audio = %+v, want wav with bytes", audio) } } // TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。 func TestSpeechHTMLEscapingDisabled(t *testing.T) { var gotRaw []byte client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { gotRaw, _ = io.ReadAll(r.Body) _, _ = w.Write(wavBytes("ok")) }) if _, err := client.Speech(context.Background(), "ad"); err != nil { t.Fatalf("Speech: %v", err) } if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) { t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw)) } } // TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。 func TestSpeechRejectsNon200(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`)) }) _, err := client.Speech(context.Background(), chineseSpeech) if !errors.Is(err, ErrUpstream) { t.Fatalf("err = %v, want ErrUpstream", err) } if !strings.Contains(err.Error(), "400") { t.Errorf("错误里应带状态码: %v", err) } if strings.Contains(err.Error(), "苏梦") { t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err) } if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 { t.Errorf("stats = %+v, want 1 attempt / 1 failure", got) } } // TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。 func TestSpeechTimeout(t *testing.T) { release := make(chan struct{}) t.Cleanup(func() { close(release) }) handler := func(w http.ResponseWriter, r *http.Request) { select { case <-release: case <-time.After(2 * time.Second): } _, _ = w.Write(wavBytes("late")) } client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond})) start := time.Now() _, err := client.Speech(context.Background(), chineseSpeech) if !errors.Is(err, ErrUnreachable) { t.Fatalf("err = %v, want ErrUnreachable", err) } if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("err = %v, want wraps context.DeadlineExceeded", err) } if elapsed := time.Since(start); elapsed > time.Second { t.Errorf("超时没有生效(用了 %v)", elapsed) } } // TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。 func TestSpeechRejectsNonAudio(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`)) }) _, err := client.Speech(context.Background(), chineseSpeech) if !errors.Is(err, ErrNotAudio) { t.Fatalf("err = %v, want ErrNotAudio", err) } if got := client.Stats(); got.Failures != 1 { t.Errorf("stats = %+v, want 1 failure", got) } } // TestSpeechRejectsOversizeResponse 钉住体积上限。 func TestSpeechRejectsOversizeResponse(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(wavBytes(strings.Repeat("x", 128))) }, WithMaxResponseBytes(32)) _, err := client.Speech(context.Background(), chineseSpeech) if !errors.Is(err, ErrTooLarge) { t.Fatalf("err = %v, want ErrTooLarge", err) } } // TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。 func TestSpeechGuards(t *testing.T) { var hits atomic.Int64 client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write(wavBytes("ok")) }) if _, err := client.Speech(context.Background(), " "); !errors.Is(err, ErrEmptyText) { t.Errorf("空文本 err = %v, want ErrEmptyText", err) } disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger())) if _, err := disabled.Speech(context.Background(), chineseSpeech); !errors.Is(err, ErrDisabled) { t.Errorf("未启用 err = %v, want ErrDisabled", err) } // 被拒的两次都不该打网络;未启用也不该被计成"失败"。 if got := hits.Load(); got != 0 { t.Errorf("上游被调用 %d 次,want 0", got) } if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 { t.Errorf("未启用却计了数: %+v", got) } } // TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。 func TestConfigNormalized(t *testing.T) { got := Config{}.Normalized(discardLogger()) if got.Enabled { t.Errorf("零值配置不该被悄悄启用") } if got.BaseURL != DefaultBaseURL || got.Character != DefaultCharacter || got.Format != DefaultFormat || got.Timeout != DefaultTimeout { t.Fatalf("默认值不对: %+v", got) } if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" { t.Errorf("归一化不对: %+v", custom) } if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat { t.Errorf("非法格式应回落默认: %+v", bad) } }