Files
goclaw/tests/integration/tts_dual_read_sqlite_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

199 lines
5.5 KiB
Go

//go:build sqliteonly && integration
package integration
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore"
_ "modernc.org/sqlite"
)
// testSQLiteDB creates an in-memory SQLite database for testing.
func testSQLiteDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { db.Close() })
// Initialize SQLite schema
if err := sqlitestore.EnsureSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
return db
}
// TestTTSConfig_Store_RoundTrip_LegacyKeys_SQLite verifies that legacy flat keys
// stored in system_configs can be read back (SQLite backend).
func TestTTSConfig_Store_RoundTrip_LegacyKeys_SQLite(t *testing.T) {
db := testSQLiteDB(t)
tenantID := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
configStore := sqlitestore.NewSQLiteSystemConfigStore(db)
t.Cleanup(func() {
db.Exec("DELETE FROM system_configs WHERE key LIKE 'tts.%'")
})
// Set legacy flat keys
if err := configStore.Set(ctx, "tts.provider", "openai"); err != nil {
t.Fatalf("Set provider: %v", err)
}
if err := configStore.Set(ctx, "tts.openai.voice", "alloy"); err != nil {
t.Fatalf("Set voice: %v", err)
}
// Read them back
provider, err := configStore.Get(ctx, "tts.provider")
if err != nil {
t.Fatalf("Get provider: %v", err)
}
if provider != "openai" {
t.Errorf("provider: got %q, want openai", provider)
}
voice, err := configStore.Get(ctx, "tts.openai.voice")
if err != nil {
t.Fatalf("Get voice: %v", err)
}
if voice != "alloy" {
t.Errorf("voice: got %q, want alloy", voice)
}
}
// TestTTSConfig_Store_RoundTrip_ParamsBlob_SQLite verifies that params blob
// stored in system_configs can be read back (SQLite backend).
func TestTTSConfig_Store_RoundTrip_ParamsBlob_SQLite(t *testing.T) {
db := testSQLiteDB(t)
tenantID := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
configStore := sqlitestore.NewSQLiteSystemConfigStore(db)
t.Cleanup(func() {
db.Exec("DELETE FROM system_configs WHERE key LIKE 'tts.%'")
})
// Set provider and params blob
configStore.Set(ctx, "tts.provider", "elevenlabs")
paramsBlob := `{"voice_settings":{"stability":0.8}}`
configStore.Set(ctx, "tts.elevenlabs.params", paramsBlob)
// Read them back
provider, _ := configStore.Get(ctx, "tts.provider")
if provider != "elevenlabs" {
t.Errorf("provider: got %q, want elevenlabs", provider)
}
retrieved, _ := configStore.Get(ctx, "tts.elevenlabs.params")
if retrieved != paramsBlob {
t.Errorf("params blob: got %q, want %q", retrieved, paramsBlob)
}
}
// TestTTSConfig_Store_Dual_LegacyAndBlob_SQLite verifies that both legacy keys
// and params blob can coexist (SQLite backend).
func TestTTSConfig_Store_Dual_LegacyAndBlob_SQLite(t *testing.T) {
db := testSQLiteDB(t)
tenantID := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
configStore := sqlitestore.NewSQLiteSystemConfigStore(db)
t.Cleanup(func() {
db.Exec("DELETE FROM system_configs WHERE key LIKE 'tts.%'")
})
// Set both legacy and blob
configStore.Set(ctx, "tts.provider", "minimax")
configStore.Set(ctx, "tts.minimax.voice", "Wise_Woman")
configStore.Set(ctx, "tts.minimax.params", `{"speed":1.5}`)
// Retrieve both
voice, _ := configStore.Get(ctx, "tts.minimax.voice")
if voice != "Wise_Woman" {
t.Errorf("voice: got %q, want Wise_Woman", voice)
}
params, _ := configStore.Get(ctx, "tts.minimax.params")
if params != `{"speed":1.5}` {
t.Errorf("params: got %q, want {\"speed\":1.5}", params)
}
}
// TestTTSConfig_Store_DisjointUnion_SQLite verifies disjoint storage and retrieval
// (SQLite backend).
func TestTTSConfig_Store_DisjointUnion_SQLite(t *testing.T) {
db := testSQLiteDB(t)
tenantID := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
configStore := sqlitestore.NewSQLiteSystemConfigStore(db)
t.Cleanup(func() {
db.Exec("DELETE FROM system_configs WHERE key LIKE 'tts.%'")
})
// Set disjoint: legacy voice, blob params
configStore.Set(ctx, "tts.provider", "edge")
configStore.Set(ctx, "tts.edge.voice", "en-US-AriaNeural")
configStore.Set(ctx, "tts.edge.params", `{"rate":0.8,"pitch":1.2}`)
// Retrieve
voice, _ := configStore.Get(ctx, "tts.edge.voice")
if voice != "en-US-AriaNeural" {
t.Errorf("voice: got %q", voice)
}
params, _ := configStore.Get(ctx, "tts.edge.params")
if params != `{"rate":0.8,"pitch":1.2}` {
t.Errorf("params: got %q", params)
}
}
// TestTTSConfig_Store_MultiTenant_SQLite verifies tenant isolation in system_configs
// (SQLite backend).
func TestTTSConfig_Store_MultiTenant_SQLite(t *testing.T) {
db := testSQLiteDB(t)
tenantA := uuid.New()
tenantB := uuid.New()
ctxA := store.WithTenantID(context.Background(), tenantA)
ctxB := store.WithTenantID(context.Background(), tenantB)
configStore := sqlitestore.NewSQLiteSystemConfigStore(db)
t.Cleanup(func() {
db.Exec("DELETE FROM system_configs WHERE key LIKE 'tts.%'")
})
// Tenant A sets openai
configStore.Set(ctxA, "tts.provider", "openai")
// Tenant B sets elevenlabs
configStore.Set(ctxB, "tts.provider", "elevenlabs")
// Verify isolation
providerA, _ := configStore.Get(ctxA, "tts.provider")
if providerA != "openai" {
t.Errorf("tenant A: got %q, want openai", providerA)
}
providerB, _ := configStore.Get(ctxB, "tts.provider")
if providerB != "elevenlabs" {
t.Errorf("tenant B: got %q, want elevenlabs", providerB)
}
}