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

273 lines
8.2 KiB
Go

package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/google/uuid"
)
// capsDescribableProvider is a TTSProvider that implements DescribableProvider.
type capsDescribableProvider struct{ name string }
func (p *capsDescribableProvider) Name() string { return p.name }
func (p *capsDescribableProvider) Synthesize(_ context.Context, _ string, _ audio.TTSOptions) (*audio.SynthResult, error) {
return nil, nil
}
func (p *capsDescribableProvider) Capabilities() audio.ProviderCapabilities {
return audio.ProviderCapabilities{
Provider: p.name,
DisplayName: p.name + " TTS",
Models: []string{"model-x"},
}
}
// newCapsMux creates a ServeMux wired with a TTSHandler (which handles capabilities).
func newCapsMux(mgr *audio.Manager) *http.ServeMux {
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
// TestCapabilitiesHandler_RequiresAuth verifies 401 when no token is provided
// and a gateway token is configured.
func TestCapabilitiesHandler_RequiresAuth(t *testing.T) {
setupTestToken(t, "caps-test-token")
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newCapsMux(mgr)
req := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestCapabilitiesHandler_RoleOperator verifies viewer gets 403, operator gets 200.
func TestCapabilitiesHandler_RoleOperator(t *testing.T) {
setupTestToken(t, "caps-test-token")
viewerRaw := "viewer-caps-key"
operatorRaw := "operator-caps-key"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(viewerRaw): {
ID: uuid.New(),
Scopes: []string{"operator.read"}, // viewer-level scope
},
crypto.HashAPIKey(operatorRaw): {
ID: uuid.New(),
Scopes: []string{"operator.write"}, // operator scope
},
})
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
mux := newCapsMux(mgr)
// Viewer → 403
req := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+viewerRaw)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("viewer: want 403, got %d: %s", rr.Code, rr.Body.String())
}
// Operator → 200
req2 := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
req2.Header.Set("Authorization", "Bearer "+operatorRaw)
rr2 := httptest.NewRecorder()
mux.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("operator: want 200, got %d: %s", rr2.Code, rr2.Body.String())
}
}
// TestCapabilitiesHandler_JSONShape verifies response decodes into {providers: []ProviderCapabilities}.
func TestCapabilitiesHandler_JSONShape(t *testing.T) {
setupTestToken(t, "caps-test-token")
operatorRaw := "operator-shape-key"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(operatorRaw): {
ID: uuid.New(),
Scopes: []string{"operator.write"},
},
})
mgr := audio.NewManager(audio.ManagerConfig{Primary: "describable"})
mgr.RegisterTTS(&capsDescribableProvider{name: "describable"})
mux := newCapsMux(mgr)
req := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+operatorRaw)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Providers []audio.ProviderCapabilities `json:"providers"`
}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Providers) == 0 {
t.Error("expected at least 1 provider in response")
}
if resp.Providers[0].Provider == "" {
t.Error("first provider has empty Provider field")
}
}
// TestCapabilitiesHandler_TenantIsolation verifies capabilities are catalog-level
// (not tenant-scoped) — same response for two different tenant tokens.
func TestCapabilitiesHandler_TenantIsolation(t *testing.T) {
setupTestToken(t, "caps-test-token")
tenant1Key := "tenant1-caps-key"
tenant2Key := "tenant2-caps-key"
tid1 := uuid.New()
tid2 := uuid.New()
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(tenant1Key): {
ID: uuid.New(),
Scopes: []string{"operator.write"},
TenantID: tid1,
},
crypto.HashAPIKey(tenant2Key): {
ID: uuid.New(),
Scopes: []string{"operator.write"},
TenantID: tid2,
},
})
mgr := audio.NewManager(audio.ManagerConfig{Primary: "prov-a"})
mgr.RegisterTTS(&capsDescribableProvider{name: "prov-a"})
mux := newCapsMux(mgr)
getProviders := func(token string) []audio.ProviderCapabilities {
req := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("token %s: want 200, got %d", token, rr.Code)
}
var resp struct {
Providers []audio.ProviderCapabilities `json:"providers"`
}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
return resp.Providers
}
p1 := getProviders(tenant1Key)
p2 := getProviders(tenant2Key)
if len(p1) != len(p2) {
t.Errorf("tenant isolation: different provider counts: %d vs %d", len(p1), len(p2))
}
for i := range p1 {
if p1[i].Provider != p2[i].Provider {
t.Errorf("tenant isolation: provider[%d] differs: %q vs %q", i, p1[i].Provider, p2[i].Provider)
}
}
}
// TestCapabilitiesHandler_BuiltinCatalog verifies that capabilities returns
// the full builtin catalog (5 providers) even when none are registered in the
// manager — frontend needs this so users can configure a fresh provider.
func TestCapabilitiesHandler_BuiltinCatalog(t *testing.T) {
setupTestToken(t, "")
mgr := audio.NewManager(audio.ManagerConfig{}) // no providers registered
mux := newCapsMux(mgr)
req := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Providers []audio.ProviderCapabilities `json:"providers"`
}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
want := map[string]bool{"openai": true, "elevenlabs": true, "edge": true, "minimax": true, "gemini": true}
got := make(map[string]bool, len(resp.Providers))
for _, p := range resp.Providers {
got[p.Provider] = true
}
for name := range want {
if !got[name] {
t.Errorf("builtin catalog missing %q; got providers=%v", name, got)
}
}
// Gemini specifically must expose static voices for the frontend voice picker.
for _, p := range resp.Providers {
if p.Provider == "gemini" {
if len(p.Voices) == 0 {
t.Error("gemini capabilities returned 0 voices — frontend will fall back to ElevenLabs picker")
}
break
}
}
}
// TestCapabilitiesHandler_RegisteredOverridesBuiltin verifies that a registered
// provider takes precedence over the builtin catalog entry of the same name.
func TestCapabilitiesHandler_RegisteredOverridesBuiltin(t *testing.T) {
setupTestToken(t, "")
mgr := audio.NewManager(audio.ManagerConfig{})
// Register a stub that returns DisplayName "custom-openai" for provider "openai".
mgr.RegisterTTS(&capsDescribableProvider{name: "openai"})
mux := newCapsMux(mgr)
req := httptest.NewRequest("GET", "/v1/tts/capabilities", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
var resp struct {
Providers []audio.ProviderCapabilities `json:"providers"`
}
_ = json.NewDecoder(rr.Body).Decode(&resp)
var openaiCount int
var openaiDisplay string
for _, p := range resp.Providers {
if p.Provider == "openai" {
openaiCount++
openaiDisplay = p.DisplayName
}
}
if openaiCount != 1 {
t.Errorf("want exactly 1 openai entry, got %d", openaiCount)
}
if openaiDisplay != "openai TTS" {
t.Errorf("registered provider should override builtin, got DisplayName=%q", openaiDisplay)
}
}