mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
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.
311 lines
10 KiB
Go
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)
|
|
}
|
|
}
|