Files
goclaw/internal/http/tts_timeout_test.go
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

210 lines
7.0 KiB
Go

package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// sleepingTTSProvider is a test-only TTS provider that sleeps for a configurable
// duration before returning, used to exercise handler timeout paths.
type sleepingTTSProvider struct {
sleepMs int
}
func (s *sleepingTTSProvider) Name() string { return "sleep" }
func (s *sleepingTTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
select {
case <-time.After(time.Duration(s.sleepMs) * time.Millisecond):
return &audio.SynthResult{Audio: []byte("ok"), MimeType: "audio/mpeg"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// stubSystemConfigStore returns configured values for "tts.timeout_ms" and ignores others.
type stubSystemConfigStore struct {
timeoutMsValue string // raw string returned for "tts.timeout_ms"
}
func (s *stubSystemConfigStore) Get(_ context.Context, key string) (string, error) {
if key == "tts.timeout_ms" {
return s.timeoutMsValue, nil
}
return "", nil
}
func (s *stubSystemConfigStore) Set(_ context.Context, _, _ string) error { return nil }
func (s *stubSystemConfigStore) Delete(_ context.Context, _ string) error { return nil }
func (s *stubSystemConfigStore) List(_ context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
// newTTSMuxWithStore builds a TTSHandler backed by mgr and systemConfigs, wires routes.
func newTTSMuxWithStore(mgr *audio.Manager, sc *stubSystemConfigStore) *http.ServeMux {
h := NewTTSHandler(mgr)
h.SetStores(sc, nil)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
// synthRequestBody builds the POST /v1/tts/synthesize JSON body.
func synthRequestBody(t *testing.T, text string) *bytes.Buffer {
t.Helper()
b, _ := json.Marshal(map[string]string{"text": text})
return bytes.NewBuffer(b)
}
// --- Synthesize timeout tests ---
// TestSynthesize_UsesTenantTimeoutMs verifies the handler applies tts.timeout_ms from
// tenant config. Backend sleeps 1000ms with tenant timeout=500ms → expect 504.
// Backend sleeps 100ms with tenant timeout=500ms → expect 200.
func TestSynthesize_UsesTenantTimeoutMs(t *testing.T) {
setupTestToken(t, "") // dev mode — no auth required
sc := &stubSystemConfigStore{timeoutMsValue: "500"}
// Slow path: backend sleeps 1000ms, tenant timeout 500ms → 504.
provider := &sleepingTTSProvider{sleepMs: 1000}
mgr := audio.NewManager(audio.ManagerConfig{})
mgr.RegisterTTS(provider)
mux := newTTSMuxWithStore(mgr, sc)
req := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusGatewayTimeout {
t.Errorf("want 504 (tenant timeout 500ms, backend sleeps 1000ms), got %d: %s", rr.Code, rr.Body.String())
}
// Fast path: backend sleeps 100ms, tenant timeout 500ms → 200.
provider2 := &sleepingTTSProvider{sleepMs: 100}
mgr2 := audio.NewManager(audio.ManagerConfig{})
mgr2.RegisterTTS(provider2)
mux2 := newTTSMuxWithStore(mgr2, sc)
req2 := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req2.Header.Set("Content-Type", "application/json")
rr2 := httptest.NewRecorder()
mux2.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("want 200 (tenant timeout 500ms, backend sleeps 100ms), got %d: %s", rr2.Code, rr2.Body.String())
}
}
// TestSynthesize_DefaultTimeoutWhenTenantUnset verifies that when tts.timeout_ms is
// unset, the handler uses defaultSynthesizeTimeoutMs (>=120000ms, not old 15s).
func TestSynthesize_DefaultTimeoutWhenTenantUnset(t *testing.T) {
setupTestToken(t, "") // dev mode
sc := &stubSystemConfigStore{timeoutMsValue: ""} // no tenant timeout
provider := &sleepingTTSProvider{sleepMs: 100}
mgr := audio.NewManager(audio.ManagerConfig{})
mgr.RegisterTTS(provider)
mux := newTTSMuxWithStore(mgr, sc)
req := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("want 200 (default timeout unset, backend fast), got %d: %s", rr.Code, rr.Body.String())
}
// Assert the default constant is >=120000ms (not the old 15s).
if defaultSynthesizeTimeoutMs < 120000 {
t.Errorf("defaultSynthesizeTimeoutMs must be >=120000, got %d", defaultSynthesizeTimeoutMs)
}
}
// TestSynthesize_TenantTimeoutInvalidFallsBackToDefault verifies that an invalid
// (non-numeric) tts.timeout_ms falls back to the 120s default and allows fast backends.
func TestSynthesize_TenantTimeoutInvalidFallsBackToDefault(t *testing.T) {
setupTestToken(t, "") // dev mode
sc := &stubSystemConfigStore{timeoutMsValue: "abc"} // invalid
provider := &sleepingTTSProvider{sleepMs: 100}
mgr := audio.NewManager(audio.ManagerConfig{})
mgr.RegisterTTS(provider)
mux := newTTSMuxWithStore(mgr, sc)
req := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("want 200 (invalid tenant value → default, backend fast), got %d: %s", rr.Code, rr.Body.String())
}
}
// --- Test-connection timeout resolution tests ---
// TestTestConnection_ReqTimeoutOverridesTenant verifies that a non-zero req.TimeoutMs
// overrides the tenant config value (req=500, tenant=5000 → effective=500).
func TestTestConnection_ReqTimeoutOverridesTenant(t *testing.T) {
sc := &stubSystemConfigStore{timeoutMsValue: "5000"} // tenant=5000ms
tenantMs := loadTenantTTSTimeoutMs(context.Background(), sc)
if tenantMs != 5000 {
t.Fatalf("precondition: tenant timeout should be 5000, got %d", tenantMs)
}
// Simulate handler priority: req.TimeoutMs > 0 → use req value.
reqTimeoutMs := 500
effectiveMs := reqTimeoutMs
if effectiveMs <= 0 {
effectiveMs = tenantMs
}
if effectiveMs <= 0 {
effectiveMs = defaultTestConnectionTimeoutMs
}
if effectiveMs != 500 {
t.Errorf("effectiveMs should be 500 (req override), got %d", effectiveMs)
}
}
// TestTestConnection_TenantFallbackWhenReqZero verifies that when req.TimeoutMs=0,
// the handler falls back to the saved tenant config value (tenant=800 → effective=800).
func TestTestConnection_TenantFallbackWhenReqZero(t *testing.T) {
sc := &stubSystemConfigStore{timeoutMsValue: "800"} // tenant=800ms
tenantMs := loadTenantTTSTimeoutMs(context.Background(), sc)
if tenantMs != 800 {
t.Fatalf("precondition: tenant timeout should be 800, got %d", tenantMs)
}
// Simulate handler priority: req.TimeoutMs=0 → fall back to tenant.
reqTimeoutMs := 0
effectiveMs := reqTimeoutMs
if effectiveMs <= 0 {
effectiveMs = tenantMs
}
if effectiveMs <= 0 {
effectiveMs = defaultTestConnectionTimeoutMs
}
if effectiveMs != 800 {
t.Errorf("effectiveMs should be 800 (tenant fallback), got %d", effectiveMs)
}
}