M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话 是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名 URL,带凭据),上游报错常把它原样回显。 - ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要; message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜 - errors.Is(err, ErrProviderFailed) 不变(熔断口径不动) - 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
269 lines
10 KiB
Go
269 lines
10 KiB
Go
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)
|
||
}
|
||
}
|