package voice import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // TestDashscopeCloner_Ok asserts the create_voice request body shape // (model/action/target_model/prefix/url) and the voice_id parsing. 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 asserts the empty prefix falls back to the // configured VOICE_CLONE_PREFIX default ("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 asserts target_model tracks the config // TTSModel (must match the synthesis model). 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 asserts a missing API key degrades. 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 asserts an empty reference URL is rejected. 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 asserts a non-alphanumeric or >10-char prefix is // rejected before any request is sent. 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 asserts a top-level message in an HTTP 200 // response is surfaced as an error (no 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) } } // TestDashscopeTTS_Instruction_ClonedVoice asserts a cloned voice carries the // configured instruction in the request input. 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 asserts a system voice (longxiaochun_v3) // never receives a free-form instruction even when one is configured. 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 asserts a per-request ctx // instruction wins over the configured one for a cloned voice. 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) } }