Files
xiaoxia-saas/tests/unit/test_voice_clone_workflow.py
T
灵应 24ac12167c
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 168h3m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 168h3m46s
Deploy / Deploy Staging (push) Failing after 168h7m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 168h7m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 168h7m45s
feat(task-308): 音色克隆完整流程实现
- CosyVoiceService: 新增 submit_clone_task() 和 check_task_status() 非阻塞方法
- VoiceCloneWorkflowService: 编排层,处理 start_clone/process_result/process_failure/retry
- Celery 任务 process_voice_clone: 异步轮询 CosyVoice 克隆结果
- API 路由: 创建后触发 Celery 异步任务,支持重试
- 57 个单元测试全部通过(workflow 14 + task 6 + cosyvoice 37)
2026-07-02 12:46:44 +08:00

323 lines
12 KiB
Python
Raw 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.
"""VoiceCloneWorkflowService 单元测试。"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
CosyVoiceError,
CosyVoiceService,
)
from packages.application.voice_clone.use_cases import (
VoiceCloneNotFoundError,
VoiceCloneNotRetryableError,
)
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
def _make_profile(
*,
status: VoiceCloneStatus = VoiceCloneStatus.PENDING,
source_audio_url: str = "https://example.com/audio.wav",
retry_count: int = 0,
max_retries: int = 3,
metadata: dict | None = None,
) -> VoiceCloneProfile:
"""创建测试用 VoiceCloneProfile。"""
profile = VoiceCloneProfile.create(
user_id="user-123",
name="测试音色",
source_audio_url=source_audio_url,
max_retries=max_retries,
metadata=metadata,
)
profile.status = status
profile.retry_count = retry_count
return profile
def _make_service(
*,
repo: MagicMock | None = None,
cosyvoice: MagicMock | None = None,
) -> VoiceCloneWorkflowService:
"""创建测试用 VoiceCloneWorkflowService。"""
mock_repo = repo or MagicMock()
mock_cosyvoice = cosyvoice or MagicMock(spec=CosyVoiceService)
return VoiceCloneWorkflowService(
repository=mock_repo, cosyvoice_service=mock_cosyvoice
)
# ── start_clone ──────────────────────────────────────────
class TestStartClone:
"""测试 start_clone 方法。"""
def test_start_clone_with_async_task(self) -> None:
"""异步模式:提交任务后返回 processing 状态的 profile。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
# CosyVoice 返回 task_id(异步模式)
mock_cosyvoice.submit_clone_task.return_value = {
"task_id": "task-abc",
"voice_id": "",
"request_id": "req-123",
}
# repo.create 和 repo.update 返回传入的 profile
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.PROCESSING
assert profile.metadata["cosyvoice_task_id"] == "task-abc"
assert profile.metadata["cosyvoice_request_id"] == "req-123"
mock_cosyvoice.submit_clone_task.assert_called_once()
assert mock_repo.create.call_count == 1
# update 至少调用 2 次:mark_processing + 保存 task_id
assert mock_repo.update.call_count >= 2
def test_start_clone_with_sync_result(self) -> None:
"""同步模式:CosyVoice 直接返回 voice_idprofile 变为 ready。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.return_value = {
"task_id": "",
"voice_id": "voice-sync-123",
"request_id": "req-456",
}
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.READY
assert profile.voice_id == "voice-sync-123"
def test_start_clone_cosyvoice_error(self) -> None:
"""CosyVoice 提交失败,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("API 调用失败")
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.FAILED
assert "API 调用失败" in profile.error_message
def test_start_clone_auth_error(self) -> None:
"""CosyVoice 认证失败,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceAuthError("认证失败")
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.FAILED
assert "认证失败" in profile.error_message
def test_start_clone_without_audio_url(self) -> None:
"""没有音频 URL 时,profile 保持 processing 状态。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="",
)
# 没有音频 URL,保持 processing 状态
assert profile.status == VoiceCloneStatus.PROCESSING
mock_cosyvoice.submit_clone_task.assert_not_called()
# ── process_clone_result ─────────────────────────────────
class TestProcessCloneResult:
"""测试 process_clone_result 方法。"""
def test_process_clone_result_success(self) -> None:
"""克隆成功,profile 标记为 ready。"""
mock_repo = MagicMock()
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo)
result = service.process_clone_result(profile.id, "voice-xyz")
assert result.status == VoiceCloneStatus.READY
assert result.voice_id == "voice-xyz"
def test_process_clone_result_not_found(self) -> None:
"""profile 不存在时抛出异常。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.process_clone_result("nonexistent", "voice-xyz")
# ── process_clone_failure ────────────────────────────────
class TestProcessCloneFailure:
"""测试 process_clone_failure 方法。"""
def test_process_clone_failure(self) -> None:
"""克隆失败,profile 标记为 failed。"""
mock_repo = MagicMock()
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo)
result = service.process_clone_failure(profile.id, "超时错误")
assert result.status == VoiceCloneStatus.FAILED
assert result.error_message == "超时错误"
def test_process_clone_failure_not_found(self) -> None:
"""profile 不存在时抛出异常。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.process_clone_failure("nonexistent", "错误")
# ── retry_clone ──────────────────────────────────────────
class TestRetryClone:
"""测试 retry_clone 方法。"""
def test_retry_clone_with_async_task(self) -> None:
"""重试成功,异步模式。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(
status=VoiceCloneStatus.FAILED, retry_count=1, max_retries=3
)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.return_value = {
"task_id": "task-retry",
"voice_id": "",
"request_id": "req-retry",
}
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.PROCESSING
assert result.metadata["cosyvoice_task_id"] == "task-retry"
assert result.retry_count == 2 # prepare_retry 增加了一次
def test_retry_clone_with_sync_result(self) -> None:
"""重试成功,同步模式。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(
status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3
)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.return_value = {
"task_id": "",
"voice_id": "voice-retry-sync",
"request_id": "req-retry",
}
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.READY
assert result.voice_id == "voice-retry-sync"
def test_retry_clone_not_found(self) -> None:
"""profile 不存在时抛出异常。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.retry_clone("nonexistent", "user-123")
def test_retry_clone_not_retryable(self) -> None:
"""不可重试时抛出异常。"""
mock_repo = MagicMock()
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
mock_repo.get.return_value = profile
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotRetryableError):
service.retry_clone(profile.id, "user-123")
def test_retry_clone_cosyvoice_error(self) -> None:
"""重试时 CosyVoice 失败,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(
status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3
)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("重试失败")
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.FAILED
assert "重试失败" in result.error_message