package http import ( "context" "encoding/json" "errors" "maps" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) type validationSystemConfigStore struct { mu sync.Mutex data map[string]string setErr error setKeys []string } func (s *validationSystemConfigStore) Get(ctx context.Context, key string) (string, error) { s.mu.Lock() defer s.mu.Unlock() return s.data[key], nil } func (s *validationSystemConfigStore) Set(ctx context.Context, key, value string) error { s.mu.Lock() defer s.mu.Unlock() s.setKeys = append(s.setKeys, key) if s.setErr != nil { return s.setErr } if s.data == nil { s.data = map[string]string{} } s.data[key] = value return nil } func (s *validationSystemConfigStore) Delete(ctx context.Context, key string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.data, key) return nil } func (s *validationSystemConfigStore) List(ctx context.Context) (map[string]string, error) { s.mu.Lock() defer s.mu.Unlock() out := make(map[string]string, len(s.data)) maps.Copy(out, s.data) return out, nil } type validationSecretsStore struct { mu sync.Mutex data map[string]string } func (s *validationSecretsStore) Get(ctx context.Context, key string) (string, error) { s.mu.Lock() defer s.mu.Unlock() return s.data[key], nil } func (s *validationSecretsStore) Set(ctx context.Context, key, value string) error { s.mu.Lock() defer s.mu.Unlock() if s.data == nil { s.data = map[string]string{} } s.data[key] = value return nil } func (s *validationSecretsStore) Delete(ctx context.Context, key string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.data, key) return nil } func (s *validationSecretsStore) GetAll(ctx context.Context) (map[string]string, error) { s.mu.Lock() defer s.mu.Unlock() out := make(map[string]string, len(s.data)) maps.Copy(out, s.data) return out, nil } func newValidationTTSConfigMux(sc store.SystemConfigStore, cs store.ConfigSecretsStore) *http.ServeMux { h := NewTTSConfigHandler(sc, cs) mux := http.NewServeMux() h.RegisterRoutes(mux) return mux } func newValidationTTSConfigMuxWithTenants(sc store.SystemConfigStore, cs store.ConfigSecretsStore, ts store.TenantStore) *http.ServeMux { h := NewTTSConfigHandler(sc, cs, ts) mux := http.NewServeMux() h.RegisterRoutes(mux) return mux } func TestTTSConfigRequiresTenantAdminForReadAndWrite(t *testing.T) { setupTestToken(t, "gateway-token") setupTestNoAuthFallback(t, false) ts := newMockTenantStore() tenantID := uuid.New() ts.addTenant(tenantID, "acme") ts.setUserRole(tenantID, "viewer-user", store.TenantRoleViewer) ts.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin) setupTestTenantStore(t, ts) sc := &validationSystemConfigStore{data: map[string]string{}} cs := &validationSecretsStore{data: map[string]string{}} mux := newValidationTTSConfigMuxWithTenants(sc, cs, ts) viewerGet := httptest.NewRequest("GET", "/v1/tts/config", nil) viewerGet.Header.Set("Authorization", "Bearer gateway-token") viewerGet.Header.Set("X-GoClaw-User-Id", "viewer-user") viewerGet.Header.Set("X-GoClaw-Tenant-Id", "acme") viewerGetRR := httptest.NewRecorder() mux.ServeHTTP(viewerGetRR, viewerGet) if viewerGetRR.Code != http.StatusForbidden { t.Fatalf("viewer GET status = %d, want 403", viewerGetRR.Code) } viewerPost := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"edge"}`)) viewerPost.Header.Set("Authorization", "Bearer gateway-token") viewerPost.Header.Set("X-GoClaw-User-Id", "viewer-user") viewerPost.Header.Set("X-GoClaw-Tenant-Id", "acme") viewerPostRR := httptest.NewRecorder() mux.ServeHTTP(viewerPostRR, viewerPost) if viewerPostRR.Code != http.StatusForbidden { t.Fatalf("viewer POST status = %d, want 403", viewerPostRR.Code) } adminPost := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"edge"}`)) adminPost.Header.Set("Authorization", "Bearer gateway-token") adminPost.Header.Set("X-GoClaw-User-Id", "admin-user") adminPost.Header.Set("X-GoClaw-Tenant-Id", "acme") adminPostRR := httptest.NewRecorder() mux.ServeHTTP(adminPostRR, adminPost) if adminPostRR.Code != http.StatusOK { t.Fatalf("tenant admin POST status = %d, want 200: %s", adminPostRR.Code, adminPostRR.Body.String()) } } func TestTTSConfigSave_AcceptsLegacyAndUISchemaAliases(t *testing.T) { setupTestToken(t, "") sc := &validationSystemConfigStore{data: map[string]string{}} cs := &validationSecretsStore{data: map[string]string{}} mux := newValidationTTSConfigMux(sc, cs) req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{ "provider": "elevenlabs", "timeout_ms": 1234, "elevenlabs": map[string]string{ "base_url": "https://custom.elevenlabs.invalid", "voice_id": "voice-123", "model_id": "model-123", "api_key": "xi-test", }, "edge": map[string]any{ "voice": "en-US-AvaMultilingualNeural", "rate": "+15%", "enabled": false, }, })) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) } if got := sc.data["tts.elevenlabs.api_base"]; got != "https://custom.elevenlabs.invalid" { t.Fatalf("want elevenlabs api_base persisted, got %q", got) } if got := sc.data["tts.elevenlabs.voice"]; got != "voice-123" { t.Fatalf("want elevenlabs voice persisted, got %q", got) } if got := sc.data["tts.elevenlabs.model"]; got != "model-123" { t.Fatalf("want elevenlabs model persisted, got %q", got) } if got := sc.data["tts.timeout_ms"]; got != "1234" { t.Fatalf("want timeout_ms persisted, got %q", got) } if got := sc.data["tts.edge.voice"]; got != "en-US-AvaMultilingualNeural" { t.Fatalf("want edge voice persisted, got %q", got) } if got := sc.data["tts.edge.rate"]; got != "+15%" { t.Fatalf("want edge rate persisted, got %q", got) } if got := sc.data["tts.edge.enabled"]; got != "false" { t.Fatalf("want edge enabled persisted, got %q", got) } if got := cs.data["tts.elevenlabs.api_key"]; got != "xi-test" { t.Fatalf("want elevenlabs api_key persisted, got %q", got) } } func TestTTSConfigSave_ProviderPresenceSemantics(t *testing.T) { setupTestToken(t, "") t.Run("explicit empty provider disables TTS without clearing provider config", func(t *testing.T) { sc := &validationSystemConfigStore{data: map[string]string{ "tts.provider": "edge", "tts.edge.voice": "en-US-AvaMultilingualNeural", "tts.edge.rate": "+15%", "tts.edge.params": `{"pitch":10,"volume":20}`, }} cs := &validationSecretsStore{data: map[string]string{ "tts.openai.api_key": "sk-existing", }} mux := newValidationTTSConfigMux(sc, cs) req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{ "provider": "", "edge": map[string]any{ "voice": "", "rate": "", "params": nil, }, "openai": map[string]string{ "api_key": "***", }, })) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("save: want 200, got %d: %s", rr.Code, rr.Body.String()) } if got := sc.data["tts.provider"]; got != "" { t.Fatalf("tts.provider = %q, want empty", got) } if got := sc.data["tts.edge.voice"]; got != "en-US-AvaMultilingualNeural" { t.Errorf("tts.edge.voice = %q, want existing value preserved", got) } if got := sc.data["tts.edge.rate"]; got != "+15%" { t.Errorf("tts.edge.rate = %q, want existing value preserved", got) } if got := sc.data["tts.edge.params"]; got != `{"pitch":10,"volume":20}` { t.Errorf("tts.edge.params = %q, want existing value preserved", got) } if got := cs.data["tts.openai.api_key"]; got != "sk-existing" { t.Errorf("tts.openai.api_key = %q, want existing secret preserved", got) } getReq := httptest.NewRequest("GET", "/v1/tts/config", nil) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) if getRR.Code != http.StatusOK { t.Fatalf("get: want 200, got %d: %s", getRR.Code, getRR.Body.String()) } var response ttsConfigResponse if err := json.Unmarshal(getRR.Body.Bytes(), &response); err != nil { t.Fatalf("decode response: %v", err) } if response.Provider != "" { t.Fatalf("GET provider = %q, want empty", response.Provider) } }) t.Run("omitted provider leaves existing value unchanged", func(t *testing.T) { sc := &validationSystemConfigStore{data: map[string]string{"tts.provider": "edge"}} mux := newValidationTTSConfigMux(sc, &validationSecretsStore{data: map[string]string{}}) req := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{}`)) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) } if got := sc.data["tts.provider"]; got != "edge" { t.Fatalf("tts.provider = %q, want edge", got) } }) t.Run("non-empty provider still replaces existing value", func(t *testing.T) { sc := &validationSystemConfigStore{data: map[string]string{"tts.provider": "edge"}} mux := newValidationTTSConfigMux(sc, &validationSecretsStore{data: map[string]string{}}) req := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"openai"}`)) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) } if got := sc.data["tts.provider"]; got != "openai" { t.Fatalf("tts.provider = %q, want openai", got) } }) } func TestTTSConfigGet_RoundTripsCompatibilityFields(t *testing.T) { setupTestToken(t, "") sc := &validationSystemConfigStore{data: map[string]string{ "tts.provider": "elevenlabs", "tts.timeout_ms": "1234", "tts.elevenlabs.api_base": "https://custom.elevenlabs.invalid", "tts.elevenlabs.voice": "voice-123", "tts.elevenlabs.model": "model-123", "tts.edge.voice": "en-US-AvaMultilingualNeural", "tts.edge.rate": "+15%", "tts.edge.enabled": "false", }} cs := &validationSecretsStore{data: map[string]string{}} mux := newValidationTTSConfigMux(sc, cs) req := httptest.NewRequest("GET", "/v1/tts/config", 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 body map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if got, _ := body["timeout_ms"].(float64); got != 1234 { t.Fatalf("want timeout_ms 1234, got %v", body["timeout_ms"]) } elevenlabs, _ := body["elevenlabs"].(map[string]any) if got, _ := elevenlabs["api_base"].(string); got != "https://custom.elevenlabs.invalid" { t.Fatalf("want elevenlabs api_base, got %q", got) } if got, _ := elevenlabs["base_url"].(string); got != "https://custom.elevenlabs.invalid" { t.Fatalf("want elevenlabs base_url alias, got %q", got) } if got, _ := elevenlabs["voice_id"].(string); got != "voice-123" { t.Fatalf("want elevenlabs voice_id alias, got %q", got) } if got, _ := elevenlabs["model_id"].(string); got != "model-123" { t.Fatalf("want elevenlabs model_id alias, got %q", got) } edge, _ := body["edge"].(map[string]any) if got, _ := edge["voice"].(string); got != "en-US-AvaMultilingualNeural" { t.Fatalf("want edge voice, got %q", got) } if got, _ := edge["rate"].(string); got != "+15%" { t.Fatalf("want edge rate, got %q", got) } if got, ok := edge["enabled"].(bool); !ok || got { t.Fatalf("want edge enabled=false, got %#v", edge["enabled"]) } } // TestSaveAndLoad_Gemini verifies that Gemini config round-trips through save/load. func TestSaveAndLoad_Gemini(t *testing.T) { setupTestToken(t, "") sc := &validationSystemConfigStore{data: map[string]string{}} cs := &validationSecretsStore{data: map[string]string{}} mux := newValidationTTSConfigMux(sc, cs) // Save req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{ "provider": "gemini", "gemini": map[string]string{ "api_key": "gm-test-key", "voice": "Kore", "model": "gemini-2.5-flash-preview-tts", "speakers": `[{"speaker":"Joe","voice_id":"Kore"}]`, }, })) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("save: want 200, got %d: %s", rr.Code, rr.Body.String()) } // Verify system_configs if got := sc.data["tts.gemini.voice"]; got != "Kore" { t.Errorf("tts.gemini.voice = %q, want Kore", got) } if got := sc.data["tts.gemini.model"]; got != "gemini-2.5-flash-preview-tts" { t.Errorf("tts.gemini.model = %q, want gemini-2.5-flash-preview-tts", got) } if got := sc.data["tts.gemini.speakers"]; got == "" { t.Error("tts.gemini.speakers not persisted") } // Verify secret stored if got := cs.data["tts.gemini.api_key"]; got != "gm-test-key" { t.Errorf("tts.gemini.api_key = %q, want gm-test-key", got) } // Load (GET) getReq := httptest.NewRequest("GET", "/v1/tts/config", nil) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) if getRR.Code != http.StatusOK { t.Fatalf("get: want 200, got %d: %s", getRR.Code, getRR.Body.String()) } var body map[string]any if err := json.Unmarshal(getRR.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } geminiResp, _ := body["gemini"].(map[string]any) if geminiResp == nil { t.Fatal("gemini block missing in response") } if got, _ := geminiResp["api_key"].(string); got != "***" { t.Errorf("gemini api_key = %q, want masked '***'", got) } if got, _ := geminiResp["voice"].(string); got != "Kore" { t.Errorf("gemini voice = %q, want Kore", got) } } func TestTTSConfigSave_WriteFailuresReturnError(t *testing.T) { setupTestToken(t, "") sc := &validationSystemConfigStore{data: map[string]string{}, setErr: errors.New("boom")} cs := &validationSecretsStore{data: map[string]string{}} mux := newValidationTTSConfigMux(sc, cs) req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{ "provider": "openai", "openai": map[string]string{ "api_base": "https://example.invalid/v1", }, })) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("want 500, got %d: %s", rr.Code, rr.Body.String()) } if rr.Body.String() == "{\"ok\":true}\n" { t.Fatalf("unexpected success body: %q", rr.Body.String()) } }