Files
viettranx 613b6e38d7 feat(tts): provider capabilities schema + Gemini TTS + dynamic param forms
Baseline groundwork for TTS expansion plan. Introduces a capabilities
system that the follow-up plan (phase-01..04) builds on.

Backend:
- `audio.ParamSchema` / `ProviderCapabilities` types + per-provider
  capabilities.go for edge, elevenlabs, minimax, openai, gemini.
- Gemini TTS provider (client, models, voices, wav encoder,
  multi-speaker via SpeakerVoice, audio-tag aware prompts).
- TTSOptions gains `Params map[string]any` (read-only) + `Speakers`.
- `VoiceListProvider` interface decouples HTTP voice handler from
  provider-specific impls.
- `nested_keys.go` resolves dot-separated param paths for nested
  provider bodies (voice_settings.stability etc.).
- Characterization + defaults-invariant tests per provider lock
  nil-params byte-equivalence before new params land.
- `/v1/tts/capabilities` HTTP endpoint + integration coverage.
- Dual-read tests (PG + SQLite) for tts_config.

Frontend (web + desktop):
- `DynamicParamForm` with depends-on evaluation, split into
  fields/logic modules. Slider primitive added.
- `AudioTagPicker` + `MultiSpeakerEditor` for Gemini.
- `voice-picker` refactored toward portal UX; combobox tightened.
- tts-capabilities API client + typed hooks.
- i18n catalogs (en/vi/zh) expanded; parity tests guard key drift.
- TTS page reorganised (voice-model-section removed; playground +
  credentials + provider-setup split cleanly).

Docs: codebase-summary, project-changelog, tts-provider-capabilities.
2026-04-20 00:05:19 +07:00

311 lines
10 KiB
Go

package http_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mockVoiceListProvider is a simple test double for audio.VoiceListProvider.
type mockVoiceListProvider struct {
voices []audio.Voice
err error
}
func (m *mockVoiceListProvider) ListVoices(_ context.Context) ([]audio.Voice, error) {
return m.voices, m.err
}
const voicesTestToken = "voices-test-token"
func TestVoicesHandler_Unauthenticated(t *testing.T) {
httpapi.InitGatewayToken(voicesTestToken)
t.Cleanup(func() { httpapi.InitGatewayToken("") })
cache := audio.NewVoiceCache(time.Hour, 100)
h := httpapi.NewVoicesHandler(cache, nil, nil)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestVoicesHandler_CachedResponse verifies that a cache hit skips the upstream call.
func TestVoicesHandler_CachedResponse(t *testing.T) {
// No gateway token → dev mode, everyone is admin, MasterTenantID assigned.
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
cache := audio.NewVoiceCache(time.Hour, 100)
voices := []audio.Voice{{ID: "v1", Name: "Bella", Category: "premade"}}
// Seed for MasterTenantID — that's what dev-mode auth injects.
cache.Set(store.MasterTenantID, voices)
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
p := elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: "k", BaseURL: upstream.URL})
h := httpapi.NewVoicesHandlerWithProvider(cache, p)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if called {
t.Error("upstream ElevenLabs should NOT be called on cache hit")
}
var resp struct {
Voices []audio.Voice `json:"voices"`
}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Voices) != 1 || resp.Voices[0].ID != "v1" {
t.Errorf("unexpected voices: %+v", resp.Voices)
}
}
// TestVoicesHandler_LiveFetch verifies a cache miss triggers live fetch and caches result.
func TestVoicesHandler_LiveFetch(t *testing.T) {
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"voices":[{"voice_id":"v2","name":"Adam","category":"premade"}]}`))
}))
defer upstream.Close()
cache := audio.NewVoiceCache(time.Hour, 100)
p := elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: "k", BaseURL: upstream.URL})
h := httpapi.NewVoicesHandlerWithProvider(cache, p)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Voices []audio.Voice `json:"voices"`
}
json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Voices) != 1 || resp.Voices[0].ID != "v2" {
t.Errorf("unexpected voices: %+v", resp.Voices)
}
// Verify cache was populated.
cached, ok := cache.Get(store.MasterTenantID)
if !ok || len(cached) != 1 {
t.Error("expected live fetch result to be cached")
}
}
// TestVoicesHandler_RefreshUnauthenticated verifies POST /refresh requires auth.
func TestVoicesHandler_RefreshUnauthenticated(t *testing.T) {
httpapi.InitGatewayToken(voicesTestToken)
t.Cleanup(func() { httpapi.InitGatewayToken("") })
cache := audio.NewVoiceCache(time.Hour, 100)
h := httpapi.NewVoicesHandler(cache, nil, nil)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("POST", "/v1/voices/refresh", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated refresh, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestVoicesHandler_RefreshAdmin verifies POST /refresh works for admin (dev mode).
func TestVoicesHandler_RefreshAdmin(t *testing.T) {
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"voices":[{"voice_id":"v3","name":"Rachel"}]}`))
}))
defer upstream.Close()
cache := audio.NewVoiceCache(time.Hour, 100)
p := elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: "k", BaseURL: upstream.URL})
h := httpapi.NewVoicesHandlerWithProvider(cache, p)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("POST", "/v1/voices/refresh", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for admin refresh, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Voices []audio.Voice `json:"voices"`
}
json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Voices) != 1 || resp.Voices[0].ID != "v3" {
t.Errorf("unexpected voices after refresh: %+v", resp.Voices)
}
}
// TestVoicesHandler_InterfaceRefactor_Elevenlabs verifies that NewVoicesHandlerWithProvider
// accepts an audio.VoiceListProvider (not a concrete *elevenlabs.TTSProvider).
func TestVoicesHandler_InterfaceRefactor_Elevenlabs(t *testing.T) {
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"voices":[{"voice_id":"el-v1","name":"Aria","category":"premade"}]}`))
}))
defer upstream.Close()
// Inject as audio.VoiceListProvider interface — confirms the refactor works.
var p audio.VoiceListProvider = elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: "k", BaseURL: upstream.URL})
cache := audio.NewVoiceCache(time.Hour, 100)
h := httpapi.NewVoicesHandlerWithProvider(cache, p)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Voices []audio.Voice `json:"voices"`
}
json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Voices) != 1 || resp.Voices[0].ID != "el-v1" {
t.Errorf("unexpected voices: %+v", resp.Voices)
}
}
// TestVoicesHandler_MiniMaxBranch verifies a mock VoiceListProvider returning
// Category-grouped entries is served correctly.
func TestVoicesHandler_MiniMaxBranch(t *testing.T) {
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
mockVoices := []audio.Voice{
{ID: "S1", Name: "System Voice", Category: "system"},
{ID: "C1", Name: "Cloned Voice", Category: "cloning"},
}
mock := &mockVoiceListProvider{voices: mockVoices}
cache := audio.NewVoiceCache(time.Hour, 100)
h := httpapi.NewVoicesHandlerWithProvider(cache, mock)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Voices []audio.Voice `json:"voices"`
}
json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Voices) != 2 {
t.Fatalf("expected 2 voices, got %d", len(resp.Voices))
}
if resp.Voices[0].Category != "system" || resp.Voices[1].Category != "cloning" {
t.Errorf("unexpected categories: %+v", resp.Voices)
}
}
// TestVoicesHandler_MiniMaxFirstFetchFailure_502 verifies that when the provider
// returns an error and no cache exists, the handler returns 502 (not 500).
func TestVoicesHandler_MiniMaxFirstFetchFailure_502(t *testing.T) {
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
mock := &mockVoiceListProvider{
voices: []audio.Voice{},
err: fmt.Errorf("upstream 500: internal server error"),
}
cache := audio.NewVoiceCache(time.Hour, 100)
h := httpapi.NewVoicesHandlerWithProvider(cache, mock)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadGateway {
t.Errorf("expected 502 on provider error, got %d: %s", rr.Code, rr.Body.String())
}
// Must not cascade to 500.
if rr.Code == http.StatusInternalServerError {
t.Error("must not return 500 — should be 502")
}
}
// TestVoicesHandler_BackwardCompat_ElevenlabsUnchanged verifies that the existing
// elevenlabs-based constructor still compiles and works (no breaking change).
func TestVoicesHandler_BackwardCompat_ElevenlabsUnchanged(t *testing.T) {
httpapi.InitGatewayToken("")
t.Cleanup(func() { httpapi.InitGatewayToken("") })
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"voices":[{"voice_id":"bc-v1","name":"BackCompat"}]}`))
}))
defer upstream.Close()
p := elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: "k", BaseURL: upstream.URL})
cache := audio.NewVoiceCache(time.Hour, 100)
// NewVoicesHandlerWithProvider now accepts audio.VoiceListProvider —
// ElevenLabs TTSProvider implements it, so this must compile.
h := httpapi.NewVoicesHandlerWithProvider(cache, p)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest("GET", "/v1/voices", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}