feat(tts): add test-connection endpoint, hot-reload support, and Telegram voice messages

- Add POST /v1/tts/test-connection for testing TTS credentials before saving
- Add UpdateManager() with RWMutex for thread-safe HTTP handler hot-reload
- Add sendVoice() for Telegram voice bubble support via audio_as_voice metadata
- Wire ttsHandler into gateway deps for config reload propagation
This commit is contained in:
viettranx
2026-04-16 17:22:56 +07:00
parent c81cf6cf39
commit 34172d2767
9 changed files with 600 additions and 6 deletions
+3 -1
View File
@@ -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
}
+1
View File
@@ -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
+3
View File
@@ -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()))
})
+60 -2
View File
@@ -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)
@@ -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)
}
})
}
}
+22 -3
View File
@@ -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
+172
View File
@@ -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)
}
}
+134
View File
@@ -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())
}
}
+134
View File
@@ -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())
}
}