diff --git a/cmd/gateway_deps.go b/cmd/gateway_deps.go index 4c860e27..0487f358 100644 --- a/cmd/gateway_deps.go +++ b/cmd/gateway_deps.go @@ -9,6 +9,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/eventbus" "github.com/nextlevelbuilder/goclaw/internal/gateway" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -34,5 +35,6 @@ type gatewayDeps struct { workspace string dataDir string domainBus eventbus.DomainEventBus - audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler + audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler + ttsHandler *httpapi.TTSHandler // nil if TTS not configured; for hot-reload } diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index b497cf1d..bab60b1d 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -255,6 +255,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( ttsH.SetRateLimiter(rl.Allow) } d.server.SetTTSHandler(ttsH) + d.ttsHandler = ttsH // store for hot-reload } // Seed + apply builtin tool disables diff --git a/cmd/gateway_lifecycle.go b/cmd/gateway_lifecycle.go index 46b41ba4..75e2ec81 100644 --- a/cmd/gateway_lifecycle.go +++ b/cmd/gateway_lifecycle.go @@ -103,6 +103,9 @@ func (d *gatewayDeps) runLifecycle( return } deps.ttsTool.UpdateManager(newMgr) + if d.ttsHandler != nil { + d.ttsHandler.UpdateManager(newMgr) + } slog.Info("tts config reloaded", "provider", newMgr.PrimaryProvider(), "auto", string(newMgr.AutoMode())) }) diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index 1d621302..76f3a2ca 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -374,8 +374,15 @@ func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.Ou return err } case strings.HasPrefix(ct, "audio/"): - if err := c.sendAudio(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { - return err + // Voice message: use SendVoice for inline playback bubble. + if msg.Metadata["audio_as_voice"] == "true" && isVoiceCompatible(ct) { + if err := c.sendVoice(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { + return err + } + } else { + if err := c.sendAudio(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { + return err + } } default: if err := c.sendDocument(ctx, chatIDObj, media.URL, caption, replyTo, threadID); err != nil { @@ -630,6 +637,57 @@ func (c *Channel) sendAudio(ctx context.Context, chatID telego.ChatID, filePath, return err } +// sendVoice sends an audio file as a voice message (inline playable bubble). +// Telegram supports OGG (Opus), MP3, and M4A for voice messages. +func (c *Channel) sendVoice(ctx context.Context, chatID telego.ChatID, filePath, caption string, replyTo, threadID int) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open voice %s: %w", filePath, err) + } + defer file.Close() + + params := &telego.SendVoiceParams{ + ChatID: chatID, + Voice: telego.InputFile{File: file}, + Caption: caption, + } + if caption != "" { + params.ParseMode = telego.ModeHTML + } + if sendThreadID := resolveThreadIDForSend(threadID); sendThreadID > 0 { + params.MessageThreadID = sendThreadID + } + if replyTo > 0 { + params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo, AllowSendingWithoutReply: true} + } + + err = c.retrySend(ctx, "sendVoice", func() { file.Seek(0, 0) }, func(ctx context.Context) error { + _, e := c.bot.SendVoice(ctx, params) + return e + }) + if err != nil && parseErrRe.MatchString(err.Error()) { + slog.Warn("sendVoice: HTML parse failed, retrying with plain text caption", "error", err) + file.Seek(0, 0) + params.ParseMode = "" + params.Caption = stripHTML(params.Caption) + _, err = c.bot.SendVoice(ctx, params) + } + if err != nil && params.MessageThreadID != 0 && threadNotFoundRe.MatchString(err.Error()) { + slog.Warn("sendVoice: thread not found, retrying without thread", "thread_id", params.MessageThreadID) + file.Seek(0, 0) + params.MessageThreadID = 0 + _, err = c.bot.SendVoice(ctx, params) + } + return err +} + +// isVoiceCompatible returns true if content-type is supported by Telegram SendVoice. +// Supported: OGG (Opus), MP3, M4A per Telegram Bot API docs. +func isVoiceCompatible(ct string) bool { + return ct == "audio/ogg" || ct == "audio/mpeg" || ct == "audio/mp3" || + ct == "audio/m4a" || ct == "audio/x-m4a" +} + // sendDocument sends a document/file message. func (c *Channel) sendDocument(ctx context.Context, chatID telego.ChatID, filePath, caption string, replyTo, threadID int) error { file, err := os.Open(filePath) diff --git a/internal/channels/telegram/send_voice_test.go b/internal/channels/telegram/send_voice_test.go new file mode 100644 index 00000000..3f01573e --- /dev/null +++ b/internal/channels/telegram/send_voice_test.go @@ -0,0 +1,71 @@ +package telegram + +import "testing" + +// TestIsVoiceCompatible verifies the voice-compatible content type check. +func TestIsVoiceCompatible(t *testing.T) { + tests := []struct { + contentType string + want bool + }{ + // Voice-compatible types + {"audio/ogg", true}, + {"audio/mpeg", true}, + {"audio/mp3", true}, + {"audio/m4a", true}, + {"audio/x-m4a", true}, + + // Non-voice audio types + {"audio/wav", false}, + {"audio/flac", false}, + {"audio/aac", false}, + {"audio/webm", false}, + + // Non-audio types + {"video/mp4", false}, + {"image/png", false}, + {"application/octet-stream", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.contentType, func(t *testing.T) { + got := isVoiceCompatible(tt.contentType) + if got != tt.want { + t.Errorf("isVoiceCompatible(%q) = %v, want %v", tt.contentType, got, tt.want) + } + }) + } +} + +// TestVoiceMetadataRouting verifies that audio_as_voice metadata routes correctly. +// This is a unit test for the routing logic - full integration requires bot mocking. +func TestVoiceMetadataRouting(t *testing.T) { + // Test that voice-compatible audio with audio_as_voice=true should route to sendVoice. + // Test that non-voice audio or missing flag should route to sendAudio. + // This documents the expected behavior - actual bot calls require integration tests. + + tests := []struct { + name string + contentType string + audioAsVoice string + expectVoice bool + }{ + {"OGG with voice flag", "audio/ogg", "true", true}, + {"MP3 with voice flag", "audio/mpeg", "true", true}, + {"OGG without voice flag", "audio/ogg", "", false}, + {"OGG with false flag", "audio/ogg", "false", false}, + {"WAV with voice flag", "audio/wav", "true", false}, // WAV not voice-compatible + {"Video with voice flag", "video/mp4", "true", false}, // not audio + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Routing logic: use voice if metadata["audio_as_voice"] == "true" && isVoiceCompatible(ct) + shouldUseVoice := tt.audioAsVoice == "true" && isVoiceCompatible(tt.contentType) + if shouldUseVoice != tt.expectVoice { + t.Errorf("voice routing for %s: got %v, want %v", tt.name, shouldUseVoice, tt.expectVoice) + } + }) + } +} diff --git a/internal/http/tts.go b/internal/http/tts.go index 5b6fd9f2..75b874df 100644 --- a/internal/http/tts.go +++ b/internal/http/tts.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" "github.com/nextlevelbuilder/goclaw/internal/audio" @@ -20,6 +21,7 @@ import ( // TTSHandler handles POST /v1/tts/synthesize — converts text to audio via a // configured TTS provider and returns raw audio bytes with the appropriate MIME type. type TTSHandler struct { + mu sync.RWMutex manager *audio.Manager rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit) } @@ -32,10 +34,22 @@ func NewTTSHandler(mgr *audio.Manager) *TTSHandler { // SetRateLimiter injects the rate limiter function (reused from the server's global limiter). func (h *TTSHandler) SetRateLimiter(fn func(string) bool) { h.rateLimiter = fn } -// RegisterRoutes wires POST /v1/tts/synthesize onto mux with RoleOperator auth. +// UpdateManager swaps the underlying manager (hot-reload safe). +func (h *TTSHandler) UpdateManager(mgr *audio.Manager) { + if mgr == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + h.manager = mgr +} + +// RegisterRoutes wires TTS endpoints onto mux with RoleOperator auth. func (h *TTSHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/tts/synthesize", requireAuth(permissions.RoleOperator, h.handleSynthesize)) + mux.HandleFunc("POST /v1/tts/test-connection", + requireAuth(permissions.RoleOperator, h.handleTestConnection)) } // synthesizeRequest is the JSON body for POST /v1/tts/synthesize. @@ -90,15 +104,20 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) { } // Resolve provider — explicit name or fall back to manager's primary. + // Copy manager reference under read lock to allow hot-reload. + h.mu.RLock() + mgr := h.manager + h.mu.RUnlock() + name := req.Provider if name == "" { - name = h.manager.PrimaryProvider() + name = mgr.PrimaryProvider() } if name == "" { http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound) return } - p, ok := h.manager.GetProvider(name) + p, ok := mgr.GetProvider(name) if !ok { http.Error(w, fmt.Sprintf(`{"error":%q}`, "provider not found: "+name), http.StatusNotFound) return diff --git a/internal/http/tts_test_connection.go b/internal/http/tts_test_connection.go new file mode 100644 index 00000000..0cbee36d --- /dev/null +++ b/internal/http/tts_test_connection.go @@ -0,0 +1,172 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/edge" + "github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs" + "github.com/nextlevelbuilder/goclaw/internal/audio/minimax" + "github.com/nextlevelbuilder/goclaw/internal/audio/openai" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// testConnectionRequest is the JSON body for POST /v1/tts/test-connection. +type testConnectionRequest struct { + Provider string `json:"provider"` + APIKey string `json:"api_key,omitempty"` + APIBase string `json:"api_base,omitempty"` + VoiceID string `json:"voice_id,omitempty"` + ModelID string `json:"model_id,omitempty"` + GroupID string `json:"group_id,omitempty"` // MiniMax requires group_id +} + +// testConnectionResponse is the JSON response for POST /v1/tts/test-connection. +type testConnectionResponse struct { + Success bool `json:"success"` + Provider string `json:"provider,omitempty"` + LatencyMs int64 `json:"latency_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +// supportedTestProviders lists providers that support ephemeral test-connection. +var supportedTestProviders = map[string]bool{ + "openai": true, + "elevenlabs": true, + "edge": true, + "minimax": true, +} + +// providersRequiringAPIKey lists providers that need an API key. +var providersRequiringAPIKey = map[string]bool{ + "openai": true, + "elevenlabs": true, + "minimax": true, +} + +const testConnectionTimeout = 10 * time.Second + +// handleTestConnection serves POST /v1/tts/test-connection. +// Creates an ephemeral provider from request credentials and tests synthesis. +func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + locale := store.LocaleFromContext(ctx) + + // Rate limit (same as synthesize). + if h.rateLimiter != nil { + key := r.RemoteAddr + if tok := extractBearerToken(r); tok != "" { + key = "token:" + tok + } + if !h.rateLimiter(key) { + w.Header().Set("Retry-After", "60") + http.Error(w, fmt.Sprintf(`{"error":%q}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests) + return + } + } + + r.Body = http.MaxBytesReader(w, r.Body, maxSynthesizeBodyBytes) + + var req testConnectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest) + return + } + + req.Provider = strings.TrimSpace(req.Provider) + if req.Provider == "" { + http.Error(w, `{"error":"provider is required"}`, http.StatusBadRequest) + return + } + + if !supportedTestProviders[req.Provider] { + http.Error(w, fmt.Sprintf(`{"error":"unsupported provider: %s"}`, req.Provider), http.StatusBadRequest) + return + } + + if providersRequiringAPIKey[req.Provider] && req.APIKey == "" { + http.Error(w, fmt.Sprintf(`{"error":"api_key is required for %s"}`, req.Provider), http.StatusBadRequest) + return + } + + // Create ephemeral provider. + provider, err := createEphemeralTTSProvider(req) + if err != nil { + slog.Warn("tts.test-connection.provider-create-failed", "provider", req.Provider, "error", err) + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) + return + } + + // Synthesize short test text. + synthCtx, cancel := context.WithTimeout(ctx, testConnectionTimeout) + defer cancel() + + start := time.Now() + _, err = provider.Synthesize(synthCtx, "test", audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID}) + dur := time.Since(start) + + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + slog.Warn("tts.test-connection.timeout", "provider", req.Provider, "ms", dur.Milliseconds()) + writeJSON(w, http.StatusGatewayTimeout, testConnectionResponse{ + Success: false, Error: "test timeout", + }) + return + } + slog.Warn("tts.test-connection.failed", "provider", req.Provider, "error", err) + writeJSON(w, http.StatusBadGateway, testConnectionResponse{ + Success: false, Error: "upstream synthesis failed", + }) + return + } + + slog.Info("tts.test-connection.ok", "provider", req.Provider, "ms", dur.Milliseconds()) + writeJSON(w, http.StatusOK, testConnectionResponse{ + Success: true, + Provider: req.Provider, + LatencyMs: dur.Milliseconds(), + }) +} + +// createEphemeralTTSProvider creates a TTS provider from request credentials. +// The provider is ephemeral — not registered in the manager. +func createEphemeralTTSProvider(req testConnectionRequest) (audio.TTSProvider, error) { + switch req.Provider { + case "openai": + return openai.NewProvider(openai.Config{ + APIKey: req.APIKey, + APIBase: req.APIBase, + Model: req.ModelID, + Voice: req.VoiceID, + }), nil + case "elevenlabs": + return elevenlabs.NewTTSProvider(elevenlabs.Config{ + APIKey: req.APIKey, + BaseURL: req.APIBase, + VoiceID: req.VoiceID, + ModelID: req.ModelID, + }), nil + case "edge": + return edge.NewProvider(edge.Config{ + Voice: req.VoiceID, + }), nil + case "minimax": + return minimax.NewProvider(minimax.Config{ + APIKey: req.APIKey, + APIBase: req.APIBase, + GroupID: req.GroupID, + VoiceID: req.VoiceID, + Model: req.ModelID, + }), nil + default: + return nil, fmt.Errorf("unsupported provider: %s", req.Provider) + } +} diff --git a/internal/http/tts_test_connection_test.go b/internal/http/tts_test_connection_test.go new file mode 100644 index 00000000..4e6ae8c5 --- /dev/null +++ b/internal/http/tts_test_connection_test.go @@ -0,0 +1,134 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// TestTestConnection_MissingProvider verifies 400 when provider field is missing. +func TestTestConnection_MissingProvider(t *testing.T) { + setupTestToken(t, "") // dev mode + + mgr := audio.NewManager(audio.ManagerConfig{}) + mux := newTTSMux(mgr) + + req := httptest.NewRequest("POST", "/v1/tts/test-connection", + ttsBody(t, map[string]string{})) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + json.Unmarshal(rr.Body.Bytes(), &resp) + if errStr, _ := resp["error"].(string); errStr != "provider is required" { + t.Errorf("want 'provider is required', got %q", errStr) + } +} + +// TestTestConnection_UnsupportedProvider verifies 400 for unknown provider. +func TestTestConnection_UnsupportedProvider(t *testing.T) { + setupTestToken(t, "") // dev mode + + mgr := audio.NewManager(audio.ManagerConfig{}) + mux := newTTSMux(mgr) + + req := httptest.NewRequest("POST", "/v1/tts/test-connection", + ttsBody(t, map[string]string{"provider": "unknown_provider"})) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + json.Unmarshal(rr.Body.Bytes(), &resp) + if errStr, _ := resp["error"].(string); errStr != "unsupported provider: unknown_provider" { + t.Errorf("want 'unsupported provider: unknown_provider', got %q", errStr) + } +} + +// TestTestConnection_MissingAPIKey verifies 400 when API key is required but missing. +func TestTestConnection_MissingAPIKey(t *testing.T) { + setupTestToken(t, "") // dev mode + + mgr := audio.NewManager(audio.ManagerConfig{}) + mux := newTTSMux(mgr) + + req := httptest.NewRequest("POST", "/v1/tts/test-connection", + ttsBody(t, map[string]string{"provider": "openai"})) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + json.Unmarshal(rr.Body.Bytes(), &resp) + if errStr, _ := resp["error"].(string); errStr != "api_key is required for openai" { + t.Errorf("want 'api_key is required for openai', got %q", errStr) + } +} + +// TestTestConnection_EdgeNoAPIKey verifies Edge provider does not require API key. +func TestTestConnection_EdgeNoAPIKey(t *testing.T) { + setupTestToken(t, "") // dev mode + + mgr := audio.NewManager(audio.ManagerConfig{}) + mux := newTTSMux(mgr) + + req := httptest.NewRequest("POST", "/v1/tts/test-connection", + ttsBody(t, map[string]string{"provider": "edge"})) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + // Edge TTS requires edge-tts CLI. In tests, may fail with 502 if CLI not present, + // but should NOT return 400 "api_key is required". + if rr.Code == http.StatusBadRequest { + var resp map[string]any + json.Unmarshal(rr.Body.Bytes(), &resp) + if errStr, _ := resp["error"].(string); errStr == "api_key is required for edge" { + t.Error("edge provider should not require api_key") + } + } + // Either 200 (if edge-tts installed) or 502 (if not) is acceptable. +} + +// TestTestConnection_BelowOperator verifies 403 for non-operator roles. +func TestTestConnection_BelowOperator(t *testing.T) { + setupTestToken(t, ttsTestToken) // token required for auth + + viewerRaw := "test-conn-viewer-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(viewerRaw): { + ID: uuid.New(), + Scopes: []string{"operator.read"}, + }, + }) + + mgr := audio.NewManager(audio.ManagerConfig{}) + mux := newTTSMux(mgr) + + req := httptest.NewRequest("POST", "/v1/tts/test-connection", + ttsBody(t, map[string]string{"provider": "openai", "api_key": "sk-test"})) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+viewerRaw) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("want 403, got %d: %s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/http/tts_update_manager_test.go b/internal/http/tts_update_manager_test.go new file mode 100644 index 00000000..97fe1073 --- /dev/null +++ b/internal/http/tts_update_manager_test.go @@ -0,0 +1,134 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" +) + +// TestTTSHandler_UpdateManager_SwapsProvider verifies that UpdateManager changes +// the underlying provider. +func TestTTSHandler_UpdateManager_SwapsProvider(t *testing.T) { + setupTestToken(t, "") // dev mode + + // Initial provider "mock-a" + providerA := &mockTTSProvider{name: "mock-a"} + mgrA := audio.NewManager(audio.ManagerConfig{Primary: "mock-a"}) + mgrA.RegisterTTS(providerA) + + h := NewTTSHandler(mgrA) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // First request uses mock-a + req1 := httptest.NewRequest("POST", "/v1/tts/synthesize", + ttsBody(t, map[string]string{"text": "hello"})) + req1.Header.Set("Content-Type", "application/json") + rr1 := httptest.NewRecorder() + mux.ServeHTTP(rr1, req1) + if rr1.Code != http.StatusOK { + t.Fatalf("first request: want 200, got %d: %s", rr1.Code, rr1.Body.String()) + } + + // Swap to provider "mock-b" + providerB := &mockTTSProvider{name: "mock-b"} + mgrB := audio.NewManager(audio.ManagerConfig{Primary: "mock-b"}) + mgrB.RegisterTTS(providerB) + h.UpdateManager(mgrB) + + // Second request should use mock-b + req2 := httptest.NewRequest("POST", "/v1/tts/synthesize", + ttsBody(t, map[string]string{"text": "hello", "provider": "mock-b"})) + req2.Header.Set("Content-Type", "application/json") + rr2 := httptest.NewRecorder() + mux.ServeHTTP(rr2, req2) + if rr2.Code != http.StatusOK { + t.Fatalf("second request: want 200, got %d: %s", rr2.Code, rr2.Body.String()) + } + + // Old provider should no longer be accessible after swap + req3 := httptest.NewRequest("POST", "/v1/tts/synthesize", + ttsBody(t, map[string]string{"text": "hello", "provider": "mock-a"})) + req3.Header.Set("Content-Type", "application/json") + rr3 := httptest.NewRecorder() + mux.ServeHTTP(rr3, req3) + if rr3.Code != http.StatusNotFound { + t.Errorf("third request (old provider): want 404, got %d", rr3.Code) + } +} + +// TestTTSHandler_UpdateManager_ConcurrentSafe verifies no race condition +// when calling UpdateManager while requests are in flight. +// Note: Uses stateless mock to avoid false race detection in mock's capturedOpts. +func TestTTSHandler_UpdateManager_ConcurrentSafe(t *testing.T) { + setupTestToken(t, "") // dev mode + + // Use stateless mock — each provider is separate instance + mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"}) + mgr.RegisterTTS(&mockTTSProvider{name: "mock"}) + + h := NewTTSHandler(mgr) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + var wg sync.WaitGroup + const numRequests = 10 + + // Spawn concurrent requests + for i := range numRequests { + wg.Add(1) + go func(idx int) { + defer wg.Done() + body := ttsBody(t, map[string]string{"text": "hello"}) + req := httptest.NewRequest("POST", "/v1/tts/synthesize", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + // Either 200 or 404 is acceptable during swap + }(i) + } + + // Mid-way call UpdateManager with new manager + wg.Add(1) + go func() { + defer wg.Done() + newMgr := audio.NewManager(audio.ManagerConfig{Primary: "mock-new"}) + newMgr.RegisterTTS(&mockTTSProvider{name: "mock-new"}) + h.UpdateManager(newMgr) + }() + + wg.Wait() + // If we reach here without panic/race in handler code, test passes. + // Note: The handler's mutex protects manager access; mock races are test artifacts. +} + +// TestTTSHandler_UpdateManager_NilManagerNoop verifies UpdateManager(nil) +// does not panic and keeps the old manager. +func TestTTSHandler_UpdateManager_NilManagerNoop(t *testing.T) { + setupTestToken(t, "") // dev mode + + provider := &mockTTSProvider{name: "mock"} + mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"}) + mgr.RegisterTTS(provider) + + h := NewTTSHandler(mgr) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Call with nil — should not panic + h.UpdateManager(nil) + + // Request should still work with original manager + req := httptest.NewRequest("POST", "/v1/tts/synthesize", + ttsBody(t, map[string]string{"text": "hello"})) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("after nil update: want 200, got %d: %s", rr.Code, rr.Body.String()) + } +}