mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-03 06:19:03 +00:00
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:
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user