mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-05 20:22:46 +00:00
- HTTP synthesize + test-connection now read tenant tts.timeout_ms (default 120s, was hardcoded 15s/10s). Gemini client default also bumped 30s→120s so both layers align when tenant config unset. - Inline prefix "Speak naturally: " prepended to single-voice text; multi-speaker transcripts pass through unchanged. - ErrTextOnlyResponse sentinel for 400 "text generation" bodies; single-voice retries once with stronger prefix. Narrowed needle list avoids false positives on unrelated 400s. - SynthesizeWithFallbackAdapted now returns errors.Join so sentinel survives fallback chain; HTTP 422 mapping + locale-translated ForLLM in agent tool (EN/VI/ZH catalogs). - Default Gemini model bumped to gemini-3.1-flash-tts-preview.
526 lines
16 KiB
Go
526 lines
16 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
geminiPkg "github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const ttsTestToken = "tts-test-token"
|
|
|
|
// --- Mock TTS provider ---
|
|
|
|
type mockTTSProvider struct {
|
|
name string
|
|
capturedOpts audio.TTSOptions
|
|
result *audio.SynthResult
|
|
err error
|
|
block bool // if true, blocks until ctx is cancelled (timeout test)
|
|
stateless bool // if true, don't capture opts (for concurrent tests)
|
|
}
|
|
|
|
func (m *mockTTSProvider) Name() string { return m.name }
|
|
|
|
func (m *mockTTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
|
|
if !m.stateless {
|
|
m.capturedOpts = opts
|
|
}
|
|
if m.block {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
}
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
result := m.result
|
|
if result == nil {
|
|
result = &audio.SynthResult{
|
|
Audio: []byte("fake-mp3-bytes"),
|
|
Extension: "mp3",
|
|
MimeType: "audio/mpeg",
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// newTTSMux creates a ServeMux wired with a TTSHandler backed by mgr.
|
|
func newTTSMux(mgr *audio.Manager) *http.ServeMux {
|
|
h := NewTTSHandler(mgr)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
return mux
|
|
}
|
|
|
|
// ttsBody builds the JSON body for POST /v1/tts/synthesize.
|
|
func ttsBody(t *testing.T, v any) *bytes.Buffer {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return bytes.NewBuffer(b)
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
// TestSynthesize_Unauthenticated verifies 401 when a gateway token is configured
|
|
// but no Authorization header is sent.
|
|
func TestSynthesize_Unauthenticated(t *testing.T) {
|
|
setupTestToken(t, ttsTestToken)
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
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.StatusUnauthorized {
|
|
t.Errorf("want 401, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_BelowOperator verifies 403 when the caller has only Viewer role.
|
|
// Injects a viewer API key via setupTestCache so resolveAuth picks it up.
|
|
func TestSynthesize_BelowOperator(t *testing.T) {
|
|
setupTestToken(t, ttsTestToken) // token set → dev-mode fallback disabled
|
|
|
|
viewerRaw := "viewer-api-key-raw"
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey(viewerRaw): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.read"},
|
|
},
|
|
})
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]string{"text": "hello"}))
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_MissingText verifies 400 when text field is empty.
|
|
func TestSynthesize_MissingText(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode — everyone is admin
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]string{"text": ""}))
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_TextTooLong verifies 400 when text exceeds 500 chars.
|
|
func TestSynthesize_TextTooLong(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
longText := strings.Repeat("a", 501) // 501 runes — one over cap
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]string{"text": longText}))
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_UnknownProvider verifies 404 when the requested provider is not registered.
|
|
func TestSynthesize_UnknownProvider(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]string{"text": "hello", "provider": "nonexistent"}))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_InvalidElevenLabsModel verifies 422 for an unknown ElevenLabs model.
|
|
func TestSynthesize_InvalidElevenLabsModel(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
body := map[string]string{
|
|
"text": "hello",
|
|
"provider": "elevenlabs",
|
|
"model_id": "eleven_totally_fake_model",
|
|
}
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnprocessableEntity {
|
|
t.Errorf("want 422, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_Success verifies 200 with correct Content-Type and audio body.
|
|
func TestSynthesize_Success(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
expected := []byte("audio-data-bytes")
|
|
p := &mockTTSProvider{
|
|
name: "mock",
|
|
result: &audio.SynthResult{
|
|
Audio: expected,
|
|
Extension: "mp3",
|
|
MimeType: "audio/mpeg",
|
|
},
|
|
}
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(p)
|
|
mux := newTTSMux(mgr)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]string{"text": "hello world"}))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); ct != "audio/mpeg" {
|
|
t.Errorf("Content-Type = %q, want audio/mpeg", ct)
|
|
}
|
|
if !bytes.Equal(rr.Body.Bytes(), expected) {
|
|
t.Errorf("body = %q, want %q", rr.Body.Bytes(), expected)
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_Timeout verifies 504 when the provider's context is cancelled.
|
|
// We use a pre-cancelled context to trigger context.Canceled immediately from
|
|
// the mock, which the handler treats as a timeout (context.DeadlineExceeded or Canceled).
|
|
func TestSynthesize_Timeout(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
p := &mockTTSProvider{name: "mock", block: true}
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
|
|
mgr.RegisterTTS(p)
|
|
|
|
h := NewTTSHandler(mgr)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
// Pre-cancel so the mock's <-ctx.Done() fires immediately.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]string{"text": "hello"}))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(ctx)
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusGatewayTimeout {
|
|
t.Errorf("want 504, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_EdgeHonorsOpts verifies that voice_id is forwarded to the Edge provider,
|
|
// proving the C1 fix is wired through the HTTP handler end-to-end.
|
|
func TestSynthesize_EdgeHonorsOpts(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
edgeMock := &mockTTSProvider{name: "edge"}
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "edge"})
|
|
mgr.RegisterTTS(edgeMock)
|
|
mux := newTTSMux(mgr)
|
|
|
|
body := map[string]string{
|
|
"text": "xin chào",
|
|
"provider": "edge",
|
|
"voice_id": "vi-VN-HoaiMyNeural",
|
|
}
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if edgeMock.capturedOpts.Voice != "vi-VN-HoaiMyNeural" {
|
|
t.Errorf("capturedOpts.Voice = %q, want %q (voice_id not forwarded to provider)",
|
|
edgeMock.capturedOpts.Voice, "vi-VN-HoaiMyNeural")
|
|
}
|
|
}
|
|
|
|
// TestResolveTenantProvider_Gemini verifies that a tenant configured for Gemini
|
|
// produces a non-nil provider with no error.
|
|
func TestResolveTenantProvider_Gemini(t *testing.T) {
|
|
setupTestToken(t, "")
|
|
|
|
sc := &validationSystemConfigStore{data: map[string]string{
|
|
"tts.provider": "gemini",
|
|
"tts.gemini.voice": "Kore",
|
|
"tts.gemini.model": "gemini-3.1-flash-tts-preview",
|
|
}}
|
|
cs := &validationSecretsStore{data: map[string]string{
|
|
"tts.gemini.api_key": "test-gemini-key",
|
|
}}
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{})
|
|
h := NewTTSHandler(mgr)
|
|
h.systemConfigs = sc
|
|
h.configSecrets = cs
|
|
|
|
provider, name, _, err := h.resolveTenantProvider(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("resolveTenantProvider error: %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("expected non-nil provider")
|
|
}
|
|
if name != "gemini" {
|
|
t.Errorf("name = %q, want gemini", name)
|
|
}
|
|
}
|
|
|
|
// TestLoadSavedSpeakers_Gemini verifies persisted multi-speaker config reactivates
|
|
// at synthesize time (M1 fix) so saved Gemini setups don't silently fall through
|
|
// to single-voice mode.
|
|
func TestLoadSavedSpeakers_Gemini(t *testing.T) {
|
|
ctx := context.Background()
|
|
sc := &validationSystemConfigStore{data: map[string]string{
|
|
"tts.gemini.speakers": `[{"speaker":"Joe","voiceId":"Kore"},{"speaker":"Jane","voiceId":"Puck"}]`,
|
|
}}
|
|
|
|
speakers := loadSavedSpeakers(ctx, sc, "gemini")
|
|
if len(speakers) != 2 {
|
|
t.Fatalf("speakers len = %d, want 2", len(speakers))
|
|
}
|
|
if speakers[0].Speaker != "Joe" || speakers[0].VoiceID != "Kore" {
|
|
t.Errorf("speakers[0] = %+v, want {Joe Kore}", speakers[0])
|
|
}
|
|
if speakers[1].Speaker != "Jane" || speakers[1].VoiceID != "Puck" {
|
|
t.Errorf("speakers[1] = %+v, want {Jane Puck}", speakers[1])
|
|
}
|
|
|
|
// Nil store → nil result.
|
|
if got := loadSavedSpeakers(ctx, nil, "gemini"); got != nil {
|
|
t.Errorf("nil store: got %v, want nil", got)
|
|
}
|
|
// Non-Gemini provider → nil (other providers don't persist multi-speaker).
|
|
if got := loadSavedSpeakers(ctx, sc, "openai"); got != nil {
|
|
t.Errorf("openai: got %v, want nil", got)
|
|
}
|
|
// Empty blob → nil.
|
|
empty := &validationSystemConfigStore{data: map[string]string{}}
|
|
if got := loadSavedSpeakers(ctx, empty, "gemini"); got != nil {
|
|
t.Errorf("empty: got %v, want nil", got)
|
|
}
|
|
// Malformed JSON → nil (no panic, no error propagation).
|
|
bad := &validationSystemConfigStore{data: map[string]string{"tts.gemini.speakers": "not json"}}
|
|
if got := loadSavedSpeakers(ctx, bad, "gemini"); got != nil {
|
|
t.Errorf("bad json: got %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_ValidElevenLabsModel verifies that a known ElevenLabs model passes through.
|
|
func TestSynthesize_ValidElevenLabsModel(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
|
|
mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
|
|
mux := newTTSMux(mgr)
|
|
|
|
body := map[string]string{
|
|
"text": "hello",
|
|
"provider": "elevenlabs",
|
|
"model_id": "eleven_multilingual_v2",
|
|
}
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("want 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_TextOnlyErrorMappedTo422 verifies that ErrTextOnlyResponse
|
|
// is mapped to HTTP 422 with the EN i18n message in the response body.
|
|
func TestSynthesize_TextOnlyErrorMappedTo422(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
mock := &mockTTSProvider{
|
|
name: "gemini",
|
|
stateless: true,
|
|
err: fmt.Errorf("wrap: %w", geminiPkg.ErrTextOnlyResponse),
|
|
}
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
|
|
mgr.RegisterProvider(mock)
|
|
mux := newTTSMux(mgr)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]any{"text": "hello", "provider": "gemini"}))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("want 422, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
got, _ := resp["error"].(string)
|
|
want := i18n.T("en", i18n.MsgTtsGeminiTextOnly)
|
|
if got != want {
|
|
t.Errorf("want error %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_TextOnly_LocaleVI verifies that the VI locale translation
|
|
// is returned when Accept-Language: vi is set.
|
|
func TestSynthesize_TextOnly_LocaleVI(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode
|
|
|
|
mock := &mockTTSProvider{
|
|
name: "gemini",
|
|
stateless: true,
|
|
err: fmt.Errorf("wrap: %w", geminiPkg.ErrTextOnlyResponse),
|
|
}
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
|
|
mgr.RegisterProvider(mock)
|
|
mux := newTTSMux(mgr)
|
|
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
|
|
ttsBody(t, map[string]any{"text": "hello", "provider": "gemini"}))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept-Language", "vi")
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("want 422, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
got, _ := resp["error"].(string)
|
|
want := i18n.T("vi", i18n.MsgTtsGeminiTextOnly)
|
|
if got != want {
|
|
t.Errorf("want VI error %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// TestSynthesize_GeminiInvalidVoice_I18n verifies that 422 responses for
|
|
// Gemini ErrInvalidVoice use i18n.T(locale, ...) — not err.Error() — so
|
|
// VI and ZH callers receive translated messages (M2-b carry-over).
|
|
func TestSynthesize_GeminiInvalidVoice_I18n(t *testing.T) {
|
|
setupTestToken(t, "") // dev mode — no bearer token required
|
|
|
|
for _, tc := range []struct {
|
|
locale string
|
|
wantBody string
|
|
}{
|
|
{
|
|
locale: "en",
|
|
wantBody: i18n.T("en", i18n.MsgTtsGeminiInvalidVoice, "bad-voice"),
|
|
},
|
|
{
|
|
locale: "vi",
|
|
wantBody: i18n.T("vi", i18n.MsgTtsGeminiInvalidVoice, "bad-voice"),
|
|
},
|
|
{
|
|
locale: "zh",
|
|
wantBody: i18n.T("zh", i18n.MsgTtsGeminiInvalidVoice, "bad-voice"),
|
|
},
|
|
} {
|
|
t.Run("locale="+tc.locale, func(t *testing.T) {
|
|
// Register a mock "gemini" provider that always returns ErrInvalidVoice.
|
|
mock := &mockTTSProvider{
|
|
name: "gemini",
|
|
stateless: true,
|
|
err: fmt.Errorf("wrap: %w", geminiPkg.ErrInvalidVoice),
|
|
}
|
|
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
|
|
mgr.RegisterProvider(mock)
|
|
mux := newTTSMux(mgr)
|
|
|
|
body := ttsBody(t, map[string]any{
|
|
"text": "hello",
|
|
"provider": "gemini",
|
|
"voice_id": "bad-voice",
|
|
})
|
|
req := httptest.NewRequest("POST", "/v1/tts/synthesize", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// Locale is extracted from Accept-Language by requireAuth → enrichContext → extractLocale.
|
|
// Setting it directly on the context is overwritten; set the header instead.
|
|
req.Header.Set("Accept-Language", tc.locale)
|
|
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("locale=%s: want 422, got %d: %s", tc.locale, rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
got, _ := resp["error"].(string)
|
|
if got != tc.wantBody {
|
|
t.Errorf("locale=%s: want error %q, got %q", tc.locale, tc.wantBody, got)
|
|
}
|
|
})
|
|
}
|
|
}
|