Files
AskaEth 9e88605cc8 fix(voice): CloneVoice 的 200-with-message 泄漏收口到 dashscopeOKBodyError
M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话
是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名
URL,带凭据),上游报错常把它原样回显。

- ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要;
  message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜
- errors.Is(err, ErrProviderFailed) 不变(熔断口径不动)
- 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
2026-10-26 13:50:10 +08:00

269 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package voice
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestDashscopeCloner_Ok 断言 create_voice 请求体的形状
// (model/action/target_model/prefix/url)以及 voice_id 的解析。
func TestDashscopeCloner_Ok(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/customization") {
t.Errorf("path = %q, want .../customization", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer sk" {
t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization"))
}
if sse := r.Header.Get("X-DashScope-SSE"); sse != "" {
t.Errorf("sse = %q, want empty", sse)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"output":{"voice_id":"cosyvoice-v3-flash-cyrene-abc123"}}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
voiceID, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "cyrene")
if err != nil {
t.Fatalf("CloneVoice error: %v", err)
}
if voiceID != "cosyvoice-v3-flash-cyrene-abc123" {
t.Errorf("voice_id = %q, want cosyvoice-v3-flash-cyrene-abc123", voiceID)
}
if gotBody["model"] != "voice-enrollment" {
t.Errorf("model = %v, want voice-enrollment", gotBody["model"])
}
input := gotBody["input"].(map[string]any)
if input["action"] != "create_voice" {
t.Errorf("action = %v, want create_voice", input["action"])
}
if input["target_model"] != "cosyvoice-v3-flash" {
t.Errorf("target_model = %v, want cosyvoice-v3-flash", input["target_model"])
}
if input["prefix"] != "cyrene" {
t.Errorf("prefix = %v, want cyrene", input["prefix"])
}
if input["url"] != "https://example.com/ref.wav" {
t.Errorf("url = %v, want the reference url", input["url"])
}
}
// TestDashscopeCloner_DefaultPrefix 断言空前缀回退到
// 配置的 VOICE_CLONE_PREFIX 默认值("cyrene")。
func TestDashscopeCloner_DefaultPrefix(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"voice_id":"v1"}}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk", VoiceClonePrefix: "cyrene"})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", ""); err != nil {
t.Fatalf("CloneVoice error: %v", err)
}
if p := gotBody["input"].(map[string]any)["prefix"]; p != "cyrene" {
t.Errorf("prefix = %v, want default cyrene", p)
}
}
// TestDashscopeCloner_TargetModelOverride 断言 target_model 跟随配置的
// TTSModel(必须与合成模型一致)。
func TestDashscopeCloner_TargetModelOverride(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"voice_id":"v1"}}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk", TTSModel: "cosyvoice-v3.5-plus"})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x"); err != nil {
t.Fatalf("CloneVoice error: %v", err)
}
if tm := gotBody["input"].(map[string]any)["target_model"]; tm != "cosyvoice-v3.5-plus" {
t.Errorf("target_model = %v, want cosyvoice-v3.5-plus", tm)
}
}
// TestDashscopeCloner_NotConfigured 断言缺少 API key 时降级。
func TestDashscopeCloner_NotConfigured(t *testing.T) {
t.Parallel()
cloner := NewDashscopeCloner(config.VoiceConfig{})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x"); err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
// TestDashscopeCloner_EmptyURL 断言空的参考 URL 被拒绝。
func TestDashscopeCloner_EmptyURL(t *testing.T) {
t.Parallel()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
if _, err := cloner.CloneVoice(context.Background(), " ", "x"); !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
}
// TestDashscopeCloner_BadPrefix 断言非字母数字或超过 10 字符的前缀
// 在发出任何请求之前就被拒绝。
func TestDashscopeCloner_BadPrefix(t *testing.T) {
t.Parallel()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
for _, prefix := range []string{"invalid-prefix!", "this-prefix-is-way-too-long"} {
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", prefix); !errors.Is(err, ErrProviderFailed) {
t.Errorf("prefix %q: err = %v, want ErrProviderFailed", prefix, err)
}
}
}
// TestDashscopeCloner_ModelError 断言 HTTP 200 响应中的顶层 message
// 会被作为错误暴露出来(此时没有 voice_id)。
func TestDashscopeCloner_ModelError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"invalid reference url"}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
if _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x"); !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
}
// TestDashscopeCloner_ModelErrorHidesMessage 覆盖 M41 的最后一条同族缺口:
// HTTP **200** 的响应里用顶层 message 报错(200 不会走非 2xx 的收口),而这句话是
// 上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名
// URL,带凭据),上游经常把它原样回显在 message 里。
//
// 两个断言缺一不可:① 失败必须**仍被识别**(ErrProviderFailed,熔断口径不变);
// ② error 文本里搜不到哨兵(旧实现 `%s resp.Message` 会整句带出)。
func TestDashscopeCloner_ModelErrorHidesMessage(t *testing.T) {
t.Parallel()
const echoed = "SENTINEL-CLONE-MESSAGE-3c7b"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"cannot fetch ` + echoed + `: signature expired","request_id":"req-abc"}`))
}))
defer srv.Close()
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
_, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x")
if !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
if strings.Contains(err.Error(), echoed) {
t.Errorf("error text carries the provider message: %v", err)
}
if !strings.Contains(err.Error(), "200") {
t.Errorf("err = %v, want the 200-on-business-failure fact kept", err)
}
}
// TestDashscopeTTS_Instruction_ClonedVoice 断言复刻音色会在请求 input 中
// 携带配置的 instruction。
func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"eA=="}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{
DashscopeBaseURL: srv.URL,
DashscopeAPIKey: "sk",
TTSVoiceID: "cosyvoice-v3-flash-cyrene-abc123",
VoiceTTSInstruction: "语气开朗俏皮",
})
if _, err := tts.TTS(context.Background(), "你好"); err != nil {
t.Fatalf("TTS error: %v", err)
}
if gotBody["model"] != "cosyvoice-v3-flash" {
t.Errorf("model = %v, want cosyvoice-v3-flash", gotBody["model"])
}
input := gotBody["input"].(map[string]any)
if input["instruction"] != "语气开朗俏皮" {
t.Errorf("instruction = %v, want the configured instruction", input["instruction"])
}
}
// TestDashscopeTTS_NoInstruction_SystemVoice 断言系统音色(longxiaochun_v3)
// 即使配置了 instruction 也绝不会收到自由形式的 instruction。
func TestDashscopeTTS_NoInstruction_SystemVoice(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"eA=="}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{
DashscopeBaseURL: srv.URL,
DashscopeAPIKey: "sk",
TTSVoiceID: "longxiaochun_v3",
VoiceTTSInstruction: "语气开朗俏皮",
})
if _, err := tts.TTS(context.Background(), "你好"); err != nil {
t.Fatalf("TTS error: %v", err)
}
input := gotBody["input"].(map[string]any)
if _, ok := input["instruction"]; ok {
t.Errorf("instruction present for system voice, want omitted: %v", input)
}
}
// TestDashscopeTTS_Instruction_ContextOverride 断言对复刻音色,逐请求的 ctx
// instruction 优先于配置的 instruction。
func TestDashscopeTTS_Instruction_ContextOverride(t *testing.T) {
t.Parallel()
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"eA=="}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{
DashscopeBaseURL: srv.URL,
DashscopeAPIKey: "sk",
TTSVoiceID: "cosyvoice-v3-flash-cyrene-abc123",
VoiceTTSInstruction: "语气温柔",
})
ctx := WithTTSInstruction(context.Background(), "语气开朗俏皮")
if _, err := tts.TTS(ctx, "你好"); err != nil {
t.Fatalf("TTS error: %v", err)
}
if instr := gotBody["input"].(map[string]any)["instruction"]; instr != "语气开朗俏皮" {
t.Errorf("instruction = %v, want the ctx override", instr)
}
}