From e5b5d03e3d02567ec0e9e414a586e0d127ec87e8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 11 Jul 2026 22:56:59 -0700 Subject: [PATCH] fix: persist explicit None TTS provider instead of dropping it (#1424) Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- internal/http/tts_config.go | 10 +++- internal/http/tts_config_test.go | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/internal/http/tts_config.go b/internal/http/tts_config.go index 621b388f..61803879 100644 --- a/internal/http/tts_config.go +++ b/internal/http/tts_config.go @@ -220,7 +220,7 @@ func (h *TTSConfigHandler) handleGet(w http.ResponseWriter, r *http.Request) { // ttsConfigSaveRequest is the request body for POST /v1/tts/config. type ttsConfigSaveRequest struct { - Provider string `json:"provider"` + Provider *string `json:"provider"` Auto string `json:"auto"` Mode string `json:"mode"` MaxLength int `json:"max_length"` @@ -269,7 +269,7 @@ func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { set := func(key, val, label string) bool { return saveOrFail(w, ctx, h.systemConfigs.Set, key, val, label) } - if req.Provider != "" && !set("tts.provider", req.Provider, "provider") { + if req.Provider != nil && !set("tts.provider", *req.Provider, "provider") { return } if req.Auto != "" && !set("tts.auto", req.Auto, "auto") { @@ -407,7 +407,11 @@ func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { } } - slog.Info("tts.config: saved", "tenant", tid, "provider", req.Provider) + provider := "" + if req.Provider != nil { + provider = *req.Provider + } + slog.Info("tts.config: saved", "tenant", tid, "provider", provider) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"ok": true}) } diff --git a/internal/http/tts_config_test.go b/internal/http/tts_config_test.go index 53881327..dd615216 100644 --- a/internal/http/tts_config_test.go +++ b/internal/http/tts_config_test.go @@ -207,6 +207,102 @@ func TestTTSConfigSave_AcceptsLegacyAndUISchemaAliases(t *testing.T) { } } +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, "")