mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 10:14:23 +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:
1 parent
c81cf6cf39
commit
34172d2767
9 files changed
+600
-6
No files matched your search
+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