Files
viettranx 04a9938f4f feat(tts): wire tenant timeout + fix Gemini text-only 400
- HTTP synthesize + test-connection now read tenant tts.timeout_ms
  (default 120s, was hardcoded 15s/10s). Gemini client default also
  bumped 30s→120s so both layers align when tenant config unset.
- Inline prefix "Speak naturally: " prepended to single-voice text;
  multi-speaker transcripts pass through unchanged.
- ErrTextOnlyResponse sentinel for 400 "text generation" bodies;
  single-voice retries once with stronger prefix. Narrowed needle
  list avoids false positives on unrelated 400s.
- SynthesizeWithFallbackAdapted now returns errors.Join so sentinel
  survives fallback chain; HTTP 422 mapping + locale-translated
  ForLLM in agent tool (EN/VI/ZH catalogs).
- Default Gemini model bumped to gemini-3.1-flash-tts-preview.
2026-04-23 08:31:53 +07:00

526 lines
16 KiB
Go

package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/audio"
geminiPkg "github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const ttsTestToken = "tts-test-token"
// --- Mock TTS provider ---
type mockTTSProvider struct {
name string
capturedOpts audio.TTSOptions
result *audio.SynthResult
err error
block bool // if true, blocks until ctx is cancelled (timeout test)
stateless bool // if true, don't capture opts (for concurrent tests)
}
func (m *mockTTSProvider) Name() string { return m.name }
func (m *mockTTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
if !m.stateless {
m.capturedOpts = opts
}
if m.block {
<-ctx.Done()
return nil, ctx.Err()
}
if m.err != nil {
return nil, m.err
}
result := m.result
if result == nil {
result = &audio.SynthResult{
Audio: []byte("fake-mp3-bytes"),
Extension: "mp3",
MimeType: "audio/mpeg",
}
}
return result, nil
}
// newTTSMux creates a ServeMux wired with a TTSHandler backed by mgr.
func newTTSMux(mgr *audio.Manager) *http.ServeMux {
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
// ttsBody builds the JSON body for POST /v1/tts/synthesize.
func ttsBody(t *testing.T, v any) *bytes.Buffer {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return bytes.NewBuffer(b)
}
// --- Tests ---
// TestSynthesize_Unauthenticated verifies 401 when a gateway token is configured
// but no Authorization header is sent.
func TestSynthesize_Unauthenticated(t *testing.T) {
setupTestToken(t, ttsTestToken)
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_BelowOperator verifies 403 when the caller has only Viewer role.
// Injects a viewer API key via setupTestCache so resolveAuth picks it up.
func TestSynthesize_BelowOperator(t *testing.T) {
setupTestToken(t, ttsTestToken) // token set → dev-mode fallback disabled
viewerRaw := "viewer-api-key-raw"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(viewerRaw): {
ID: uuid.New(),
Scopes: []string{"operator.read"},
},
})
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viewerRaw)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("want 403, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_MissingText verifies 400 when text field is empty.
func TestSynthesize_MissingText(t *testing.T) {
setupTestToken(t, "") // dev mode — everyone is admin
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": ""}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_TextTooLong verifies 400 when text exceeds 500 chars.
func TestSynthesize_TextTooLong(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
longText := strings.Repeat("a", 501) // 501 runes — one over cap
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": longText}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_UnknownProvider verifies 404 when the requested provider is not registered.
func TestSynthesize_UnknownProvider(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello", "provider": "nonexistent"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("want 404, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_InvalidElevenLabsModel verifies 422 for an unknown ElevenLabs model.
func TestSynthesize_InvalidElevenLabsModel(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
mux := newTTSMux(mgr)
body := map[string]string{
"text": "hello",
"provider": "elevenlabs",
"model_id": "eleven_totally_fake_model",
}
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Errorf("want 422, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_Success verifies 200 with correct Content-Type and audio body.
func TestSynthesize_Success(t *testing.T) {
setupTestToken(t, "") // dev mode
expected := []byte("audio-data-bytes")
p := &mockTTSProvider{
name: "mock",
result: &audio.SynthResult{
Audio: expected,
Extension: "mp3",
MimeType: "audio/mpeg",
},
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(p)
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello world"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if ct := rr.Header().Get("Content-Type"); ct != "audio/mpeg" {
t.Errorf("Content-Type = %q, want audio/mpeg", ct)
}
if !bytes.Equal(rr.Body.Bytes(), expected) {
t.Errorf("body = %q, want %q", rr.Body.Bytes(), expected)
}
}
// TestSynthesize_Timeout verifies 504 when the provider's context is cancelled.
// We use a pre-cancelled context to trigger context.Canceled immediately from
// the mock, which the handler treats as a timeout (context.DeadlineExceeded or Canceled).
func TestSynthesize_Timeout(t *testing.T) {
setupTestToken(t, "") // dev mode
p := &mockTTSProvider{name: "mock", block: true}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(p)
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// Pre-cancel so the mock's <-ctx.Done() fires immediately.
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusGatewayTimeout {
t.Errorf("want 504, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_EdgeHonorsOpts verifies that voice_id is forwarded to the Edge provider,
// proving the C1 fix is wired through the HTTP handler end-to-end.
func TestSynthesize_EdgeHonorsOpts(t *testing.T) {
setupTestToken(t, "") // dev mode
edgeMock := &mockTTSProvider{name: "edge"}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "edge"})
mgr.RegisterTTS(edgeMock)
mux := newTTSMux(mgr)
body := map[string]string{
"text": "xin chào",
"provider": "edge",
"voice_id": "vi-VN-HoaiMyNeural",
}
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if edgeMock.capturedOpts.Voice != "vi-VN-HoaiMyNeural" {
t.Errorf("capturedOpts.Voice = %q, want %q (voice_id not forwarded to provider)",
edgeMock.capturedOpts.Voice, "vi-VN-HoaiMyNeural")
}
}
// TestResolveTenantProvider_Gemini verifies that a tenant configured for Gemini
// produces a non-nil provider with no error.
func TestResolveTenantProvider_Gemini(t *testing.T) {
setupTestToken(t, "")
sc := &validationSystemConfigStore{data: map[string]string{
"tts.provider": "gemini",
"tts.gemini.voice": "Kore",
"tts.gemini.model": "gemini-3.1-flash-tts-preview",
}}
cs := &validationSecretsStore{data: map[string]string{
"tts.gemini.api_key": "test-gemini-key",
}}
mgr := audio.NewManager(audio.ManagerConfig{})
h := NewTTSHandler(mgr)
h.systemConfigs = sc
h.configSecrets = cs
provider, name, _, err := h.resolveTenantProvider(context.Background(), "")
if err != nil {
t.Fatalf("resolveTenantProvider error: %v", err)
}
if provider == nil {
t.Fatal("expected non-nil provider")
}
if name != "gemini" {
t.Errorf("name = %q, want gemini", name)
}
}
// TestLoadSavedSpeakers_Gemini verifies persisted multi-speaker config reactivates
// at synthesize time (M1 fix) so saved Gemini setups don't silently fall through
// to single-voice mode.
func TestLoadSavedSpeakers_Gemini(t *testing.T) {
ctx := context.Background()
sc := &validationSystemConfigStore{data: map[string]string{
"tts.gemini.speakers": `[{"speaker":"Joe","voiceId":"Kore"},{"speaker":"Jane","voiceId":"Puck"}]`,
}}
speakers := loadSavedSpeakers(ctx, sc, "gemini")
if len(speakers) != 2 {
t.Fatalf("speakers len = %d, want 2", len(speakers))
}
if speakers[0].Speaker != "Joe" || speakers[0].VoiceID != "Kore" {
t.Errorf("speakers[0] = %+v, want {Joe Kore}", speakers[0])
}
if speakers[1].Speaker != "Jane" || speakers[1].VoiceID != "Puck" {
t.Errorf("speakers[1] = %+v, want {Jane Puck}", speakers[1])
}
// Nil store → nil result.
if got := loadSavedSpeakers(ctx, nil, "gemini"); got != nil {
t.Errorf("nil store: got %v, want nil", got)
}
// Non-Gemini provider → nil (other providers don't persist multi-speaker).
if got := loadSavedSpeakers(ctx, sc, "openai"); got != nil {
t.Errorf("openai: got %v, want nil", got)
}
// Empty blob → nil.
empty := &validationSystemConfigStore{data: map[string]string{}}
if got := loadSavedSpeakers(ctx, empty, "gemini"); got != nil {
t.Errorf("empty: got %v, want nil", got)
}
// Malformed JSON → nil (no panic, no error propagation).
bad := &validationSystemConfigStore{data: map[string]string{"tts.gemini.speakers": "not json"}}
if got := loadSavedSpeakers(ctx, bad, "gemini"); got != nil {
t.Errorf("bad json: got %v, want nil", got)
}
}
// TestSynthesize_ValidElevenLabsModel verifies that a known ElevenLabs model passes through.
func TestSynthesize_ValidElevenLabsModel(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
mux := newTTSMux(mgr)
body := map[string]string{
"text": "hello",
"provider": "elevenlabs",
"model_id": "eleven_multilingual_v2",
}
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestSynthesize_TextOnlyErrorMappedTo422 verifies that ErrTextOnlyResponse
// is mapped to HTTP 422 with the EN i18n message in the response body.
func TestSynthesize_TextOnlyErrorMappedTo422(t *testing.T) {
setupTestToken(t, "") // dev mode
mock := &mockTTSProvider{
name: "gemini",
stateless: true,
err: fmt.Errorf("wrap: %w", geminiPkg.ErrTextOnlyResponse),
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterProvider(mock)
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]any{"text": "hello", "provider": "gemini"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
got, _ := resp["error"].(string)
want := i18n.T("en", i18n.MsgTtsGeminiTextOnly)
if got != want {
t.Errorf("want error %q, got %q", want, got)
}
}
// TestSynthesize_TextOnly_LocaleVI verifies that the VI locale translation
// is returned when Accept-Language: vi is set.
func TestSynthesize_TextOnly_LocaleVI(t *testing.T) {
setupTestToken(t, "") // dev mode
mock := &mockTTSProvider{
name: "gemini",
stateless: true,
err: fmt.Errorf("wrap: %w", geminiPkg.ErrTextOnlyResponse),
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterProvider(mock)
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]any{"text": "hello", "provider": "gemini"}))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "vi")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
got, _ := resp["error"].(string)
want := i18n.T("vi", i18n.MsgTtsGeminiTextOnly)
if got != want {
t.Errorf("want VI error %q, got %q", want, got)
}
}
// TestSynthesize_GeminiInvalidVoice_I18n verifies that 422 responses for
// Gemini ErrInvalidVoice use i18n.T(locale, ...) — not err.Error() — so
// VI and ZH callers receive translated messages (M2-b carry-over).
func TestSynthesize_GeminiInvalidVoice_I18n(t *testing.T) {
setupTestToken(t, "") // dev mode — no bearer token required
for _, tc := range []struct {
locale string
wantBody string
}{
{
locale: "en",
wantBody: i18n.T("en", i18n.MsgTtsGeminiInvalidVoice, "bad-voice"),
},
{
locale: "vi",
wantBody: i18n.T("vi", i18n.MsgTtsGeminiInvalidVoice, "bad-voice"),
},
{
locale: "zh",
wantBody: i18n.T("zh", i18n.MsgTtsGeminiInvalidVoice, "bad-voice"),
},
} {
t.Run("locale="+tc.locale, func(t *testing.T) {
// Register a mock "gemini" provider that always returns ErrInvalidVoice.
mock := &mockTTSProvider{
name: "gemini",
stateless: true,
err: fmt.Errorf("wrap: %w", geminiPkg.ErrInvalidVoice),
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterProvider(mock)
mux := newTTSMux(mgr)
body := ttsBody(t, map[string]any{
"text": "hello",
"provider": "gemini",
"voice_id": "bad-voice",
})
req := httptest.NewRequest("POST", "/v1/tts/synthesize", body)
req.Header.Set("Content-Type", "application/json")
// Locale is extracted from Accept-Language by requireAuth → enrichContext → extractLocale.
// Setting it directly on the context is overwritten; set the header instead.
req.Header.Set("Accept-Language", tc.locale)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("locale=%s: want 422, got %d: %s", tc.locale, rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
got, _ := resp["error"].(string)
if got != tc.wantBody {
t.Errorf("locale=%s: want error %q, got %q", tc.locale, tc.wantBody, got)
}
})
}
}