package gateway import ( "context" "encoding/base64" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) // audioStubVoice 是一个 VoiceService,为 REST /api/v1/audio/* handler // 测试返回固定的 STT/TTS 结果。 type audioStubVoice struct { sttText string sttErr error ttsAudio voice.Audio ttsErr error gotAudio voice.Audio gotText string sttCalled bool ttsCalled bool cloneVoiceID string cloneErr error cloneCalled bool gotCloneURL string gotClonePfx string gotInstr string } func (s *audioStubVoice) STT(ctx context.Context, audio voice.Audio) (string, error) { s.sttCalled = true s.gotAudio = audio return s.sttText, s.sttErr } func (s *audioStubVoice) TTS(ctx context.Context, text string) (voice.Audio, error) { s.ttsCalled = true s.gotText = text s.gotInstr = voice.TTSInstructionFromContext(ctx) return s.ttsAudio, s.ttsErr } func (s *audioStubVoice) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) { s.cloneCalled = true s.gotCloneURL = referenceURL s.gotClonePfx = prefix return s.cloneVoiceID, s.cloneErr } // PrimaryActive 让本替身**实现**网关的 `personaVoice` 扩展并报告"按 persona 选发音 // 的那一级正在生效"(复审 r3 P2-2)。 // // 🔴 它存在是为了让"关闭态不查库"那条用例的变异**真能打红**:此前 `audioStubVoice` // 不实现 `PrimaryActive`,于是拦下 persona 读取的只有 `s.voice.(personaVoice)` 这个 // **类型断言**,把 `&& pv.PrimaryActive()` 摘掉用例照样绿 —— 等于那条变异没被覆盖。 // 现在类型断言会通过,拦住它的只剩这个布尔值。要表达"未生效"的替身请用 // `inactivePersonaVoice`(`voice_emotiontts_test.go`)。 func (s *audioStubVoice) PrimaryActive() bool { return true } func TestHandleTranscribe(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{sttText: "你好世界"} env.srv.SetVoice(sv) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{ "audio": base64.StdEncoding.EncodeToString([]byte("raw")), "format": "wav", }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } if !sv.sttCalled { t.Fatal("STT not called") } if string(sv.gotAudio.Data) != "raw" || sv.gotAudio.Format != "wav" { t.Errorf("audio = %+v, want format wav / data raw", sv.gotAudio) } env2 := decodeBody(t, body) if got := string(env2["data"]); got == "" { t.Errorf("data envelope empty: %s", body) } } func TestHandleTranscribe_AuthRequired(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{sttText: "x"} env.srv.SetVoice(sv) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{ "audio": base64.StdEncoding.EncodeToString([]byte("raw")), }, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body) } } func TestHandleTranscribe_EmptyAudio(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{sttText: "x"} env.srv.SetVoice(sv) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{ "audio": base64.StdEncoding.EncodeToString([]byte("")), }, token) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } } func TestHandleSpeech(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}} env.srv.SetVoice(sv) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": "你好", }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" { t.Errorf("Content-Type = %q, want audio/wav", ct) } if string(body) != "audio-bytes" { t.Errorf("body = %q, want audio-bytes", body) } if !sv.ttsCalled || sv.gotText != "你好" { t.Errorf("TTS got text = %q (called=%v), want 你好", sv.gotText, sv.ttsCalled) } } func TestHandleSpeech_EmptyText(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(&audioStubVoice{}) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": " ", }, token) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } } func TestHandleSpeech_InstructionPassthrough(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}} env.srv.SetVoice(sv) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": "你好,我是昔涟", "instruction": "语气开朗俏皮,像女朋友撒娇", }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } if !sv.ttsCalled { t.Fatal("TTS not called") } if sv.gotText != "你好,我是昔涟" { t.Errorf("TTS text = %q, want 你好,我是昔涟", sv.gotText) } if sv.gotInstr != "语气开朗俏皮,像女朋友撒娇" { t.Errorf("instruction threaded = %q, want the per-request instruction", sv.gotInstr) } } func TestHandleSpeech_NoInstruction(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}} env.srv.SetVoice(sv) token := env.token(t, auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": "你好", }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if sv.gotInstr != "" { t.Errorf("instruction = %q, want empty when not supplied", sv.gotInstr) } } func TestHandleVoiceClone(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{cloneVoiceID: "cosyvoice-v3-flash-cyrene-abc123"} env.srv.SetVoice(sv) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{ "url": "https://example.com/ref.wav", "prefix": "xilian", }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } if !sv.cloneCalled { t.Fatal("CloneVoice not called") } if sv.gotCloneURL != "https://example.com/ref.wav" || sv.gotClonePfx != "xilian" { t.Errorf("clone url/prefix = %q / %q, want the request values", sv.gotCloneURL, sv.gotClonePfx) } env2 := decodeBody(t, body) if got := string(env2["data"]); got == "" { t.Errorf("data envelope empty: %s", body) } if !strings.Contains(string(env2["data"]), "cosyvoice-v3-flash-cyrene-abc123") { t.Errorf("voiceId missing: %s", body) } } func TestHandleVoiceClone_AuthRequired(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(&audioStubVoice{}) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{ "url": "https://example.com/ref.wav", }, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body) } } func TestHandleVoiceClone_EmptyURL(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(&audioStubVoice{}) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{ "url": " ", }, token) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } }