Files
xiaoxia-saas/tests/unit/test_cosyvoice_service.py
T
灵应 8935196fcd
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
style: 后端代码black格式化
2026-07-03 18:49:54 +08:00

663 lines
23 KiB
Python

"""CosyVoiceService 单元测试。"""
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://test.cosyvoice.api",
model: str = "cosyvoice-v1",
http_client: httpx.Client | None = None,
) -> CosyVoiceService:
"""创建测试用 CosyVoiceService。"""
return CosyVoiceService(
api_key=api_key,
base_url=base_url,
model=model,
http_client=http_client,
)
def _mock_response(
status_code: int = 200,
json_data: dict | None = None,
text: str = "",
) -> httpx.Response:
"""创建 mock HTTP 响应。"""
# httpx.Response 需要 content 参数才能正确调用 .json()
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("POST", "https://test.cosyvoice.api"),
)
# ── list_preset_voices ───────────────────────────────────
class TestListPresetVoices:
"""测试预置音色列表。"""
def test_returns_all_preset_voices(self) -> None:
"""返回所有预置音色。"""
service = _make_service()
voices = service.list_preset_voices()
assert len(voices) == 8
assert all(isinstance(v, PresetVoice) for v in voices)
def test_preset_voice_ids(self) -> None:
"""预置音色 ID 正确。"""
service = _make_service()
voices = service.list_preset_voices()
voice_ids = [v.voice_id for v in voices]
assert "longxiaochun" in voice_ids
assert "longxiaoxia" in voice_ids
assert "longxiaochen" in voice_ids
assert "longyue" in voice_ids
assert "longshu" in voice_ids
assert "longjing" in voice_ids
assert "longbo" in voice_ids
assert "longtian" in voice_ids
def test_preset_voice_has_required_fields(self) -> None:
"""预置音色包含必要字段。"""
service = _make_service()
voices = service.list_preset_voices()
for voice in voices:
assert voice.voice_id
assert voice.name
assert voice.gender in ("male", "female")
assert voice.language == "zh-CN"
# ── clone_voice ──────────────────────────────────────────
class TestCloneVoice:
"""测试音色克隆。"""
def test_clone_sync_success(self) -> None:
"""同步克隆成功(直接返回 voice_id)。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"request_id": "req-001",
"output": {"voice_id": "clone-voice-001"},
}
)
service = _make_service(http_client=mock_client)
result = service.clone_voice(
audio_url="https://example.com/audio.mp3",
voice_name="我的音色",
)
assert isinstance(result, CloneResult)
assert result.voice_id == "clone-voice-001"
assert result.request_id == "req-001"
mock_client.request.assert_called_once()
def test_clone_async_with_polling(self) -> None:
"""异步克隆(返回 task_id,轮询后成功)。"""
mock_client = MagicMock(spec=httpx.Client)
# 第一次调用:提交任务,返回 task_id
submit_response = _mock_response(
json_data={
"request_id": "req-001",
"output": {"task_id": "task-abc123", "task_status": "PENDING"},
}
)
# 第二次调用:查询状态 → RUNNING
running_response = _mock_response(
json_data={
"request_id": "req-002",
"output": {"task_status": "RUNNING"},
}
)
# 第三次调用:查询状态 → SUCCEEDED
success_response = _mock_response(
json_data={
"request_id": "req-003",
"output": {
"task_status": "SUCCEEDED",
"voice_id": "clone-voice-async-001",
},
}
)
mock_client.request.side_effect = [
submit_response,
running_response,
success_response,
]
service = _make_service(http_client=mock_client)
service.POLL_INTERVAL = 0 # 测试中不等待
result = service.clone_voice(
audio_url="https://example.com/audio.mp3",
)
assert result.voice_id == "clone-voice-async-001"
assert mock_client.request.call_count == 3
def test_clone_async_task_failed(self) -> None:
"""异步克隆任务失败。"""
mock_client = MagicMock(spec=httpx.Client)
submit_response = _mock_response(
json_data={
"output": {"task_id": "task-fail"},
}
)
failed_response = _mock_response(
json_data={
"output": {
"task_status": "FAILED",
"message": "音频质量不达标",
},
}
)
mock_client.request.side_effect = [submit_response, failed_response]
service = _make_service(http_client=mock_client)
service.POLL_INTERVAL = 0
with pytest.raises(CosyVoiceError, match="音频质量不达标"):
service.clone_voice(audio_url="https://example.com/bad.mp3")
def test_clone_empty_audio_url_raises(self) -> None:
"""空 audio_url 抛出 ValueError。"""
service = _make_service()
with pytest.raises(ValueError, match="audio_url 不能为空"):
service.clone_voice(audio_url="")
def test_clone_no_api_key_raises_auth_error(self) -> None:
"""未配置 API Key 抛出 CosyVoiceAuthError。"""
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"):
service.clone_voice(audio_url="https://example.com/audio.mp3")
def test_clone_auth_failure(self) -> None:
"""API 认证失败(401)。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(status_code=401)
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceAuthError, match="认证失败"):
service.clone_voice(audio_url="https://example.com/audio.mp3")
def test_clone_client_error_no_retry(self) -> None:
"""客户端错误(400)不重试。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(status_code=400, text="Bad Request")
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="HTTP 400"):
service.clone_voice(audio_url="https://example.com/audio.mp3")
# 客户端错误不重试,只调用一次
assert mock_client.request.call_count == 1
def test_clone_server_error_retries(self) -> None:
"""服务端错误(500)重试。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(status_code=500)
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0 # 测试中不等待
with pytest.raises(CosyVoiceError, match="服务端错误"):
service.clone_voice(audio_url="https://example.com/audio.mp3")
# 服务端错误重试 MAX_RETRIES 次
assert mock_client.request.call_count == service.MAX_RETRIES
def test_clone_timeout_retries(self) -> None:
"""超时重试。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.side_effect = httpx.TimeoutException("timeout")
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0
with pytest.raises(CosyVoiceTimeoutError):
service.clone_voice(audio_url="https://example.com/audio.mp3")
assert mock_client.request.call_count == service.MAX_RETRIES
def test_clone_with_voice_name(self) -> None:
"""带 voice_name 参数。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(json_data={"output": {"voice_id": "v-001"}})
service = _make_service(http_client=mock_client)
service.clone_voice(
audio_url="https://example.com/audio.mp3",
voice_name="测试音色",
)
call_args = mock_client.request.call_args
payload = call_args.kwargs.get("json") or call_args[1].get("json")
assert payload["parameters"]["voice_name"] == "测试音色"
def test_clone_no_task_id_or_voice_id_raises(self) -> None:
"""API 返回无效响应(无 task_id 也无 voice_id)。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(json_data={"output": {}})
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="未返回 task_id 或 voice_id"):
service.clone_voice(audio_url="https://example.com/audio.mp3")
# ── submit_clone_task ────────────────────────────────────
class TestSubmitCloneTask:
"""测试 submit_clone_task(非阻塞提交)。"""
def test_submit_async_returns_task_id(self) -> None:
"""异步模式:返回 task_id。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"request_id": "req-001",
"output": {"task_id": "task-abc", "task_status": "PENDING"},
}
)
service = _make_service(http_client=mock_client)
result = service.submit_clone_task(
audio_url="https://example.com/audio.mp3",
voice_name="测试音色",
)
assert result["task_id"] == "task-abc"
assert result["voice_id"] == ""
assert result["request_id"] == "req-001"
mock_client.request.assert_called_once()
def test_submit_sync_returns_voice_id(self) -> None:
"""同步模式:直接返回 voice_id。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"request_id": "req-002",
"output": {"voice_id": "voice-sync-001"},
}
)
service = _make_service(http_client=mock_client)
result = service.submit_clone_task(
audio_url="https://example.com/audio.mp3",
)
assert result["task_id"] == ""
assert result["voice_id"] == "voice-sync-001"
assert result["request_id"] == "req-002"
def test_submit_empty_audio_url_raises(self) -> None:
"""空 audio_url 抛出 ValueError。"""
service = _make_service()
with pytest.raises(ValueError, match="audio_url 不能为空"):
service.submit_clone_task(audio_url="")
def test_submit_no_api_key_raises(self) -> None:
"""未配置 API Key 抛出 CosyVoiceAuthError。"""
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"):
service.submit_clone_task(audio_url="https://example.com/audio.mp3")
def test_submit_no_task_id_or_voice_id_raises(self) -> None:
"""API 返回无效响应时抛出 CosyVoiceError。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(json_data={"output": {}})
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="未返回 task_id 或 voice_id"):
service.submit_clone_task(audio_url="https://example.com/audio.mp3")
def test_submit_with_voice_name_in_payload(self) -> None:
"""voice_name 参数包含在请求体中。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(json_data={"output": {"task_id": "task-001"}})
service = _make_service(http_client=mock_client)
service.submit_clone_task(
audio_url="https://example.com/audio.mp3",
voice_name="我的音色",
language="en-US",
)
call_args = mock_client.request.call_args
payload = call_args.kwargs.get("json") or call_args[1].get("json")
assert payload["parameters"]["voice_name"] == "我的音色"
assert payload["parameters"]["language"] == "en-US"
# ── check_task_status ────────────────────────────────────
class TestCheckTaskStatus:
"""测试 check_task_status(单次状态查询)。"""
def test_check_succeeded(self) -> None:
"""查询成功状态。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"output": {
"task_status": "SUCCEEDED",
"voice_id": "voice-done-001",
},
}
)
service = _make_service(http_client=mock_client)
result = service.check_task_status("task-abc")
assert result["status"] == "SUCCEEDED"
assert result["voice_id"] == "voice-done-001"
assert result["message"] == ""
def test_check_running(self) -> None:
"""查询运行中状态。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"output": {"task_status": "RUNNING"},
}
)
service = _make_service(http_client=mock_client)
result = service.check_task_status("task-abc")
assert result["status"] == "RUNNING"
assert result["voice_id"] == ""
def test_check_failed_with_message(self) -> None:
"""查询失败状态,包含错误消息。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"output": {
"task_status": "FAILED",
"message": "音频质量不达标",
},
}
)
service = _make_service(http_client=mock_client)
result = service.check_task_status("task-fail")
assert result["status"] == "FAILED"
assert result["message"] == "音频质量不达标"
def test_check_no_api_key_raises(self) -> None:
"""未配置 API Key 抛出 CosyVoiceAuthError。"""
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"):
service.check_task_status("task-abc")
def test_check_uses_correct_path(self) -> None:
"""请求路径包含 task_id。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(json_data={"output": {"task_status": "PENDING"}})
service = _make_service(http_client=mock_client)
service.check_task_status("task-xyz-123")
call_args = mock_client.request.call_args
url = call_args.kwargs.get("url") or call_args[1].get("url") or call_args[0][0]
assert "/tasks/task-xyz-123" in url
# ── synthesize_speech ────────────────────────────────────
class TestSynthesizeSpeech:
"""测试语音合成。"""
def test_synthesize_sync_success(self) -> None:
"""同步合成成功(直接返回 audio_url)。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={
"request_id": "req-tts-001",
"output": {
"audio_url": "https://cdn.example.com/audio.mp3",
"duration": 5.2,
"file_size": 83200,
},
}
)
service = _make_service(http_client=mock_client)
result = service.synthesize_speech(
text="你好世界",
voice_id="longxiaochun",
)
assert isinstance(result, SynthesizeResult)
assert result.audio_url == "https://cdn.example.com/audio.mp3"
assert result.duration == 5.2
assert result.file_size == 83200
assert result.request_id == "req-tts-001"
def test_synthesize_async_with_polling(self) -> None:
"""异步合成(返回 task_id,轮询后成功)。"""
mock_client = MagicMock(spec=httpx.Client)
submit_response = _mock_response(
json_data={
"output": {"task_id": "task-tts-001", "task_status": "PENDING"},
}
)
success_response = _mock_response(
json_data={
"output": {
"task_status": "SUCCEEDED",
"audio_url": "https://cdn.example.com/async.mp3",
"duration": 3.0,
},
}
)
mock_client.request.side_effect = [submit_response, success_response]
service = _make_service(http_client=mock_client)
service.POLL_INTERVAL = 0
result = service.synthesize_speech(
text="异步合成测试",
voice_id="longxiaoxia",
)
assert result.audio_url == "https://cdn.example.com/async.mp3"
assert result.duration == 3.0
assert mock_client.request.call_count == 2
def test_synthesize_async_task_failed(self) -> None:
"""异步合成任务失败。"""
mock_client = MagicMock(spec=httpx.Client)
submit_response = _mock_response(json_data={"output": {"task_id": "task-tts-fail"}})
failed_response = _mock_response(
json_data={
"output": {
"task_status": "FAILED",
"message": "文本过长",
},
}
)
mock_client.request.side_effect = [submit_response, failed_response]
service = _make_service(http_client=mock_client)
service.POLL_INTERVAL = 0
with pytest.raises(CosyVoiceError, match="文本过长"):
service.synthesize_speech(
text="超长文本" * 10000,
voice_id="longxiaochun",
)
def test_synthesize_empty_text_raises(self) -> None:
"""空 text 抛出 ValueError。"""
service = _make_service()
with pytest.raises(ValueError, match="text 不能为空"):
service.synthesize_speech(text="", voice_id="longxiaochun")
def test_synthesize_empty_voice_id_raises(self) -> None:
"""空 voice_id 抛出 ValueError。"""
service = _make_service()
with pytest.raises(ValueError, match="voice_id 不能为空"):
service.synthesize_speech(text="测试", voice_id="")
def test_synthesize_no_api_key_raises(self) -> None:
"""未配置 API Key 抛出 CosyVoiceAuthError。"""
service = _make_service(api_key="")
with pytest.raises(CosyVoiceAuthError, match="API Key 未配置"):
service.synthesize_speech(text="测试", voice_id="longxiaochun")
def test_synthesize_with_parameters(self) -> None:
"""带采样率、格式、语速参数。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(
json_data={"output": {"audio_url": "https://cdn.example.com/out.wav"}}
)
service = _make_service(http_client=mock_client)
service.synthesize_speech(
text="参数测试",
voice_id="longxiaochun",
sample_rate=44100,
format="wav",
speed=1.5,
)
call_args = mock_client.request.call_args
payload = call_args.kwargs.get("json") or call_args[1].get("json")
params = payload["parameters"]
assert params["sample_rate"] == 44100
assert params["format"] == "wav"
assert params["rate"] == 1.5
def test_synthesize_no_url_or_task_id_raises(self) -> None:
"""API 返回无效响应(无 audio_url 也无 task_id)。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(json_data={"output": {}})
service = _make_service(http_client=mock_client)
with pytest.raises(CosyVoiceError, match="未返回 audio_url 或 task_id"):
service.synthesize_speech(text="测试", voice_id="longxiaochun")
def test_synthesize_server_error_retries(self) -> None:
"""服务端错误重试。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(status_code=502)
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0
with pytest.raises(CosyVoiceError, match="服务端错误"):
service.synthesize_speech(text="测试", voice_id="longxiaochun")
assert mock_client.request.call_count == service.MAX_RETRIES
def test_synthesize_timeout_retries(self) -> None:
"""超时重试。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.side_effect = httpx.TimeoutException("timeout")
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0
with pytest.raises(CosyVoiceTimeoutError):
service.synthesize_speech(text="测试", voice_id="longxiaochun")
assert mock_client.request.call_count == service.MAX_RETRIES
# ── 重试逻辑 ─────────────────────────────────────────────
class TestRetryLogic:
"""测试重试逻辑。"""
def test_retry_then_success(self) -> None:
"""第一次失败,第二次成功。"""
mock_client = MagicMock(spec=httpx.Client)
# 第一次:服务端错误
error_response = _mock_response(status_code=500)
# 第二次:成功
success_response = _mock_response(json_data={"output": {"voice_id": "v-retry-ok"}})
mock_client.request.side_effect = [error_response, success_response]
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
assert result.voice_id == "v-retry-ok"
assert mock_client.request.call_count == 2
def test_max_retries_exhausted(self) -> None:
"""达到最大重试次数后抛出异常。"""
mock_client = MagicMock(spec=httpx.Client)
mock_client.request.return_value = _mock_response(status_code=503)
service = _make_service(http_client=mock_client)
service.RETRY_BACKOFF = 0
with pytest.raises(CosyVoiceError):
service.clone_voice(audio_url="https://example.com/audio.mp3")
assert mock_client.request.call_count == service.MAX_RETRIES