Files
xiaoxia-saas/tests/unit/test_cosyvoice_service.py
T
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

599 lines
22 KiB
Python
Executable File

"""CosyVoiceService 单元测试 — 适配百炼 DashScope API."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import httpx
import pytest
from packages.application.cosyvoice_service import (
CloneResult,
CosyVoiceAuthError,
CosyVoiceError,
CosyVoiceService,
CosyVoiceTimeoutError,
SynthesizeResult,
)
from packages.domain.preset_voices import PresetVoice
def _make_service(
*,
api_key: str = "test-api-key",
base_url: str = "https://dashscope.aliyuncs.com/api/v1",
model: str = "cosyvoice-v3-flash",
clone_model: str = "voice-enrollment",
http_client: httpx.Client | None = None,
audio_url_signer=None,
) -> CosyVoiceService:
return CosyVoiceService(
api_key=api_key,
base_url=base_url,
model=model,
clone_model=clone_model,
http_client=http_client,
audio_url_signer=audio_url_signer,
)
def _mock_response(
status_code: int = 200,
json_data: dict | None = None,
text: str = "",
method: str = "POST",
) -> httpx.Response:
content = b""
if json_data is not None:
content = json.dumps(json_data).encode("utf-8")
elif text:
content = text.encode("utf-8")
return httpx.Response(
status_code=status_code,
content=content,
request=httpx.Request(method, "https://dashscope.aliyuncs.com/api/v1/test"),
)
# ── list_preset_voices ───────────────────────────────────
class TestListPresetVoices:
def test_returns_preset_voices(self) -> None:
service = _make_service()
voices = service.list_preset_voices()
assert len(voices) >= 1
assert all(isinstance(v, PresetVoice) for v in voices)
# ── submit_clone_task ────────────────────────────────────
class TestSubmitCloneTask:
def test_submit_success_returns_voice_id(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{
"output": {
"voice_id": "cosyvoice-v3-flash-clone-abc123",
"status": "DEPLOYING",
},
"usage": {"count": 1},
"request_id": "req-001",
},
)
service = _make_service(http_client=mock_client)
result = service.submit_clone_task(
audio_url="https://example.com/audio.wav",
voice_name="myvoice",
)
assert result["voice_id"] == "cosyvoice-v3-flash-clone-abc123"
assert result["status"] == "DEPLOYING"
assert result["request_id"] == "req-001"
# 验证请求参数
call_args = mock_client.request.call_args
assert call_args.kwargs["method"] == "POST"
assert "/services/audio/tts/customization" in call_args.kwargs["url"]
payload = call_args.kwargs["json"]
assert payload["model"] == "voice-enrollment"
assert payload["input"]["action"] == "create_voice"
assert payload["input"]["target_model"] == "cosyvoice-v3-flash"
assert payload["input"]["prefix"] == "myvoice"
assert payload["input"]["url"] == "https://example.com/audio.wav"
assert payload["input"]["language_hints"] == ["zh"]
def test_submit_with_custom_target_model(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{
"output": {
"voice_id": "cosyvoice-v3-flash-clone-xyz",
"status": "DEPLOYING",
},
"request_id": "req-002",
},
)
service = _make_service(http_client=mock_client)
result = service.submit_clone_task(
audio_url="https://example.com/audio.wav",
target_model="cosyvoice-v3-flash",
)
payload = mock_client.request.call_args.kwargs["json"]
assert payload["input"]["target_model"] == "cosyvoice-v3-flash"
assert result["voice_id"] == "cosyvoice-v3-flash-clone-xyz"
def test_submit_empty_audio_url_raises(self) -> None:
service = _make_service()
with pytest.raises(ValueError, match="audio_url 不能为空"):
service.submit_clone_task(audio_url="")
def test_submit_no_api_key_raises_auth_error(self) -> None:
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"):
service.submit_clone_task(audio_url="https://example.com/a.wav")
def test_submit_no_voice_id_raises(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(200, {"output": {}})
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="未返回 voice_id"):
service.submit_clone_task(audio_url="https://example.com/a.wav")
def test_submit_with_audio_url_signer(self) -> None:
"""传入 audio_url_signer 时,提交前会对音频URL预签名."""
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{"output": {"voice_id": "v123", "status": "DEPLOYING"}, "request_id": "r1"},
)
def signer(url: str) -> str:
return f"{url}?signature=test"
service = _make_service(http_client=mock_client, audio_url_signer=signer)
service.submit_clone_task(audio_url="https://oss.example.com/audio.wav")
payload = mock_client.request.call_args.kwargs["json"]
assert payload["input"]["url"] == "https://oss.example.com/audio.wav?signature=test"
def test_submit_signer_failure_falls_back_to_original(self) -> None:
"""signer 失败时回退到原始URL,不崩溃."""
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{"output": {"voice_id": "v123", "status": "DEPLOYING"}, "request_id": "r1"},
)
def failing_signer(url: str) -> str:
raise RuntimeError("sign failed")
service = _make_service(http_client=mock_client, audio_url_signer=failing_signer)
result = service.submit_clone_task(audio_url="https://example.com/a.wav")
assert result["voice_id"] == "v123"
payload = mock_client.request.call_args.kwargs["json"]
assert payload["input"]["url"] == "https://example.com/a.wav" # 原始URL
def test_submit_prefix_sanitized(self) -> None:
"""voice_name 含特殊字符时清洗为合法 prefix."""
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200, {"output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1"}
)
service = _make_service(http_client=mock_client)
service.submit_clone_task(audio_url="https://e.com/a.wav", voice_name="我的音色-2024!")
payload = mock_client.request.call_args.kwargs["json"]
# 中文和特殊字符被过滤,剩下字母数字
assert (
payload["input"]["prefix"] == "2024"
or payload["input"]["prefix"] == "clone"
or len(payload["input"]["prefix"]) <= 10
)
def test_submit_auth_401_raises(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(401, text="Unauthorized")
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceAuthError, match="认证失败"):
service.submit_clone_task(audio_url="https://e.com/a.wav")
def test_submit_400_raises_with_code_message(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
400, {"code": "InvalidParameter", "message": "task can not be null"}
)
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="InvalidParameter"):
service.submit_clone_task(audio_url="https://e.com/a.wav")
# ── query_voice_status ──────────────────────────────────
class TestQueryVoiceStatus:
def test_query_deploying_status(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{
"output": {
"status": "DEPLOYING",
"target_model": "cosyvoice-v3-flash",
"gmt_create": "2026-01-01T00:00:00Z",
"gmt_modified": "2026-01-01T00:01:00Z",
"resource_link": "https://...",
},
"request_id": "req-003",
},
)
service = _make_service(http_client=mock_client)
result = service.query_voice_status("voice-123")
assert result["status"] == "DEPLOYING"
assert result["target_model"] == "cosyvoice-v3-flash"
# 验证请求
payload = mock_client.request.call_args.kwargs["json"]
assert payload["model"] == "voice-enrollment"
assert payload["input"]["action"] == "query_voice"
assert payload["input"]["voice_id"] == "voice-123"
def test_query_ok_status(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200, {"output": {"status": "OK", "target_model": "cosyvoice-v3-flash"}}
)
service = _make_service(http_client=mock_client)
result = service.query_voice_status("voice-123")
assert result["status"] == "OK"
def test_query_no_api_key_raises(self) -> None:
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError):
service.query_voice_status("v1")
def test_query_empty_voice_id_raises(self) -> None:
service = _make_service()
with pytest.raises(ValueError):
service.query_voice_status("")
# ── poll_clone_task ─────────────────────────────────────
class TestPollCloneTask:
def test_poll_ok_on_first_check(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200, {"output": {"status": "OK", "target_model": "cosyvoice-v3-flash"}}
)
service = _make_service(http_client=mock_client)
# 减少轮询间隔加速测试
service.CLONE_POLL_INTERVAL = 0.01
result = service.poll_clone_task("voice-123", timeout=30.0)
assert result["voice_id"] == "voice-123"
def test_poll_deploying_then_ok(self) -> None:
mock_client = MagicMock()
# 第一次 DEPLOYING,第二次 OK
mock_client.request.side_effect = [
_mock_response(200, {"output": {"status": "DEPLOYING"}}),
_mock_response(200, {"output": {"status": "OK"}}),
]
service = _make_service(http_client=mock_client)
service.CLONE_POLL_INTERVAL = 0.01
result = service.poll_clone_task("voice-123", timeout=30.0)
assert result["voice_id"] == "voice-123"
assert mock_client.request.call_count == 2
def test_poll_undeployed_raises_error(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(200, {"output": {"status": "UNDEPLOYED"}})
service = _make_service(http_client=mock_client)
service.CLONE_POLL_INTERVAL = 0.01
with pytest.raises(CosyVoiceError, match="审核未通过"):
service.poll_clone_task("voice-123", timeout=30.0)
def test_poll_timeout_raises(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(200, {"output": {"status": "DEPLOYING"}})
service = _make_service(http_client=mock_client)
service.CLONE_POLL_INTERVAL = 0.01
service.CLONE_MAX_POLL_ATTEMPTS = 3 # 快速失败
with pytest.raises(CosyVoiceTimeoutError):
service.poll_clone_task("voice-123", timeout=30.0)
# ── clone_voice (阻塞) ──────────────────────────────────
class TestCloneVoice:
def test_clone_already_ok_returns_immediately(self) -> None:
mock_client = MagicMock()
# submit 直接返回 OK 状态
mock_client.request.return_value = _mock_response(
200,
{
"output": {"voice_id": "voice-ok", "status": "OK"},
"request_id": "req-ok",
},
)
service = _make_service(http_client=mock_client)
result = service.clone_voice(audio_url="https://e.com/a.wav")
assert isinstance(result, CloneResult)
assert result.voice_id == "voice-ok"
assert result.request_id == "req-ok"
# 只有一次调用(submit),没有 poll
assert mock_client.request.call_count == 1
def test_clone_deploying_then_poll_ok(self) -> None:
mock_client = MagicMock()
mock_client.request.side_effect = [
# submit: 返回 DEPLOYING
_mock_response(200, {"output": {"voice_id": "v1", "status": "DEPLOYING"}, "request_id": "r1"}),
# poll 1: DEPLOYING
_mock_response(200, {"output": {"status": "DEPLOYING"}}),
# poll 2: OK
_mock_response(200, {"output": {"status": "OK"}}),
]
service = _make_service(http_client=mock_client)
service.CLONE_POLL_INTERVAL = 0.01
result = service.clone_voice(audio_url="https://e.com/a.wav")
assert isinstance(result, CloneResult)
assert result.voice_id == "v1"
assert mock_client.request.call_count == 3
# ── synthesize_speech ────────────────────────────────────
class TestSynthesizeSpeech:
def test_synthesize_success_returns_audio_url(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{
"output": {
"finish_reason": "stop",
"audio": {
"url": "https://dashscope-result.oss.com/output.mp3",
"id": "audio-001",
"expires_at": 1234567890,
},
},
"usage": {"characters": 10},
"request_id": "req-syn-001",
},
)
service = _make_service(http_client=mock_client)
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3")
assert isinstance(result, SynthesizeResult)
assert result.audio_url == "https://dashscope-result.oss.com/output.mp3"
assert result.request_id == "req-syn-001"
# 验证请求
call_args = mock_client.request.call_args
assert "/services/audio/tts/SpeechSynthesizer" in call_args.kwargs["url"]
payload = call_args.kwargs["json"]
assert payload["model"] == "cosyvoice-v3-flash"
assert payload["input"]["text"] == "你好世界"
assert payload["input"]["voice"] == "longxiaochun_v3"
assert payload["input"]["format"] == "mp3"
assert payload["input"]["sample_rate"] == 22050
assert payload["input"]["rate"] == 1.0
assert payload["input"]["volume"] == 50
def test_synthesize_with_custom_params(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{"output": {"audio": {"url": "https://e.com/out.wav", "id": "a1"}}},
)
service = _make_service(http_client=mock_client)
service.synthesize_speech(
text="test",
voice_id="v1",
sample_rate=44100,
format="wav",
speed=1.5,
volume=80,
)
payload = mock_client.request.call_args.kwargs["json"]
assert payload["input"]["sample_rate"] == 44100
assert payload["input"]["format"] == "wav"
assert payload["input"]["rate"] == 1.5
assert payload["input"]["volume"] == 80
def test_synthesize_empty_text_raises(self) -> None:
service = _make_service()
with pytest.raises(ValueError, match="text 不能为空"):
service.synthesize_speech(text="", voice_id="v1")
def test_synthesize_empty_voice_raises(self) -> None:
service = _make_service()
with pytest.raises(ValueError, match="voice_id 不能为空"):
service.synthesize_speech(text="hi", voice_id="")
def test_synthesize_no_api_key_raises(self) -> None:
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError):
service.synthesize_speech(text="hi", voice_id="v1")
def test_synthesize_no_audio_url_raises(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(200, {"output": {}})
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="未返回 audio_url"):
service.synthesize_speech(text="hi", voice_id="v1")
def test_synthesize_submit_returns_empty_task_id(self) -> None:
"""同步接口的 submit_synthesize_task 返回空 task_id 字段(兼容旧接口)."""
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(
200,
{"output": {"audio": {"url": "https://e.com/a.mp3"}}},
)
service = _make_service(http_client=mock_client)
result = service.submit_synthesize_task(text="hi", voice_id="v1")
assert result["task_id"] == "" # 同步接口无 task_id
assert result["audio_url"] == "https://e.com/a.mp3"
def test_synthesize_auth_401_raises(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(401, text="Unauthorized")
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceAuthError):
service.synthesize_speech(text="hi", voice_id="v1")
# ── retry logic ──────────────────────────────────────────
class TestRetryLogic:
def test_500_error_retries_then_succeeds(self) -> None:
mock_client = MagicMock()
mock_client.request.side_effect = [
_mock_response(500, text="Server Error"),
_mock_response(502, text="Bad Gateway"),
_mock_response(200, {"output": {"audio": {"url": "https://e.com/a.mp3"}}}),
]
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0.01 # 加速
result = service.synthesize_speech(text="hi", voice_id="v1")
assert result.audio_url == "https://e.com/a.mp3"
assert mock_client.request.call_count == 3
def test_max_retries_exhausted_raises(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(500, text="Server Error")
service = _make_service(http_client=mock_client)
service.MAX_RETRIES = 2
service.RETRY_BACKOFF = 0.01
with pytest.raises(CosyVoiceError, match="服务端错误"):
service.synthesize_speech(text="hi", voice_id="v1")
assert mock_client.request.call_count == 2
# ── sanitize_prefix ─────────────────────────────────────
class TestSanitizePrefix:
def test_alphanumeric_kept(self) -> None:
service = _make_service()
assert service._sanitize_prefix("myvoice123") == "myvoice123"
def test_special_chars_removed(self) -> None:
service = _make_service()
result = service._sanitize_prefix("my-voice_2!")
# 特殊字符被移除,只保留字母数字
assert result == "myvoice2"
def test_max_10_chars(self) -> None:
service = _make_service()
result = service._sanitize_prefix("abcdefghijklmnop")
assert len(result) == 10
def test_empty_returns_clone(self) -> None:
service = _make_service()
assert service._sanitize_prefix("!!!???") == "clone"
assert service._sanitize_prefix("") == "clone"
# ── check_task_status (兼容旧接口) ──────────────────────
class TestCheckTaskStatus:
def test_check_task_status_uses_query_voice(self) -> None:
mock_client = MagicMock()
mock_client.request.return_value = _mock_response(200, {"output": {"status": "OK"}})
service = _make_service(http_client=mock_client)
result = service.check_task_status("voice-123")
assert result["status"] == "OK"
assert result["voice_id"] == "voice-123"
# 验证走的是 query_voice 路径
payload = mock_client.request.call_args.kwargs["json"]
assert payload["input"]["action"] == "query_voice"
# ── 配置与初始化 ─────────────────────────────────────────
class TestServiceConfiguration:
"""测试 CosyVoiceService 配置与初始化逻辑."""
def test_base_url_old_text2audio_path_auto_fixed(self) -> None:
"""旧版 base_url 包含 text2audio 路径时,应自动修正为 /api/v1."""
service = CosyVoiceService(
api_key="test-key",
base_url="https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio",
model="cosyvoice-v3-flash",
)
# 应自动去掉 text2audio 后缀,保留到 /api/v1
assert service._base_url == "https://dashscope.aliyuncs.com/api/v1"
def test_base_url_normal_unchanged(self) -> None:
"""正常的 base_url 不应被修改."""
url = "https://dashscope.aliyuncs.com/api/v1"
service = CosyVoiceService(
api_key="test-key",
base_url=url,
model="cosyvoice-v3-flash",
)
assert service._base_url == url
def test_base_url_workspace_domain_unchanged(self) -> None:
"""工作空间专属域名的 base_url 不应被修改."""
url = "https://workspace-xxx.cn-beijing.maas.aliyuncs.com/api/v1"
service = CosyVoiceService(
api_key="test-key",
base_url=url,
model="cosyvoice-v3-flash",
)
assert service._base_url == url