package tts import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) // newStubPiper returns a fake Piper server that echoes a fixed WAV body and // records how many times it was called and the last request payload. func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEcho) { t.Helper() var calls int32 last := &synthEcho{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) var req map[string]any _ = json.NewDecoder(r.Body).Decode(&req) last.voice, _ = req["voice"].(string) last.text, _ = req["text"].(string) w.Header().Set("Content-Type", "audio/wav") _, _ = w.Write(body) })) t.Cleanup(srv.Close) return srv, &calls, last } type synthEcho struct{ voice, text string } // newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server. func newHandler(t *testing.T, endpoint string) *Handler { t.Helper() return &Handler{ routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}}, cacheDir: t.TempDir(), format: formats["wav"], client: http.DefaultClient, } } func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder { t.Helper() b, _ := json.Marshal(synthRequest{Text: text, Lang: lang}) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b)) rr := httptest.NewRecorder() h.synth(rr, req) return rr } func TestSynthSuccessAndVoiceSelection(t *testing.T) { wav := []byte("RIFF....fake-wav") srv, calls, last := newStubPiper(t, wav) h := newHandler(t, srv.URL) rr := post(t, h, "hello there", "en-US") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if got := rr.Body.Bytes(); !bytes.Equal(got, wav) { t.Fatalf("body = %q, want the piper wav", got) } if ct := rr.Header().Get("Content-Type"); ct != "audio/wav" { t.Fatalf("content-type = %q, want audio/wav", ct) } if last.voice != "en_US-amy-medium" { t.Fatalf("piper voice = %q, want en_US-amy-medium", last.voice) } if *calls != 1 { t.Fatalf("piper calls = %d, want 1", *calls) } } func TestRoutesByLanguageToSeparateInstances(t *testing.T) { enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav")) zhSrv, zhCalls, zhLast := newStubPiper(t, []byte("ZH-wav")) h := &Handler{ routes: map[string]route{ "en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"}, "zh": {strings.TrimRight(zhSrv.URL, "/"), "zh_CN-huayan-medium"}, }, cacheDir: t.TempDir(), format: formats["wav"], client: http.DefaultClient, } if rr := post(t, h, "你好世界", "zh-CN"); rr.Code != http.StatusOK { t.Fatalf("zh status = %d, want 200", rr.Code) } if *zhCalls != 1 || *enCalls != 0 { t.Fatalf("calls en=%d zh=%d, want en=0 zh=1 (zh routed to zh instance)", *enCalls, *zhCalls) } if zhLast.voice != "zh_CN-huayan-medium" { t.Fatalf("zh voice = %q, want zh_CN-huayan-medium", zhLast.voice) } } func TestUnknownLanguageReturns404(t *testing.T) { srv, calls, _ := newStubPiper(t, []byte("x")) h := newHandler(t, srv.URL) rr := post(t, h, "你好", "zh-CN") // only "en" is configured if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rr.Code) } if *calls != 0 { t.Fatalf("piper calls = %d, want 0 (should not synthesize)", *calls) } } func TestCacheHitSkipsPiper(t *testing.T) { srv, calls, _ := newStubPiper(t, []byte("RIFF....fake-wav")) h := newHandler(t, srv.URL) if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK { t.Fatalf("first status = %d, want 200", rr.Code) } if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK { t.Fatalf("second status = %d, want 200", rr.Code) } if *calls != 1 { t.Fatalf("piper calls = %d, want 1 (second served from cache)", *calls) } } func TestEmptyTextReturns400(t *testing.T) { srv, _, _ := newStubPiper(t, []byte("x")) h := newHandler(t, srv.URL) if rr := post(t, h, " ", "en-US"); rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } } func TestTextIsCapped(t *testing.T) { srv, _, last := newStubPiper(t, []byte("x")) h := newHandler(t, srv.URL) long := strings.Repeat("a", maxTextBytes+500) if rr := post(t, h, long, "en-US"); rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if len(last.text) != maxTextBytes { t.Fatalf("piper received %d chars, want capped to %d", len(last.text), maxTextBytes) } } func TestBaseLang(t *testing.T) { cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""} for in, want := range cases { if got := baseLang(in); got != want { t.Errorf("baseLang(%q) = %q, want %q", in, got, want) } } } // ensure the stub's body is fully consumable (guards against the LimitReader cap // accidentally truncating a normal request body in synth()). func TestRequestBodyNotTruncated(t *testing.T) { srv, _, last := newStubPiper(t, []byte("x")) h := newHandler(t, srv.URL) text := strings.Repeat("word ", 200) // ~1000 bytes, under the cap post(t, h, text, "en-US") if last.text != strings.TrimSpace(text) { t.Fatalf("piper text length = %d, want %d", len(last.text), len(strings.TrimSpace(text))) } }