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
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>
383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""VoiceCloneWorkflowService 单元测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
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_deploying(self) -> None:
|
|
"""提交克隆后返回 DEPLOYING 状态,profile 保持 processing。"""
|
|
mock_repo = MagicMock()
|
|
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
|
|
|
# CosyVoice 返回 voice_id + DEPLOYING 状态(需轮询)
|
|
mock_cosyvoice.submit_clone_task.return_value = {
|
|
"voice_id": "voice-abc",
|
|
"status": "DEPLOYING",
|
|
"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"] == "voice-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 + 保存 voice_id
|
|
assert mock_repo.update.call_count >= 2
|
|
|
|
def test_start_clone_with_ok_status(self) -> None:
|
|
"""CosyVoice 直接返回 OK 状态,profile 变为 ready。"""
|
|
mock_repo = MagicMock()
|
|
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
|
|
|
mock_cosyvoice.submit_clone_task.return_value = {
|
|
"voice_id": "voice-sync-123",
|
|
"status": "OK",
|
|
"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 保持 pending 状态(P2-1 修复后)。"""
|
|
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="",
|
|
)
|
|
|
|
# P2-1: 没有音频 URL 时不标记 processing,保持 pending
|
|
assert profile.status == VoiceCloneStatus.PENDING
|
|
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 = {
|
|
"voice_id": "voice-retry",
|
|
"status": "DEPLOYING",
|
|
"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"] == "voice-retry"
|
|
assert result.retry_count == 2 # prepare_retry 增加了一次
|
|
|
|
def test_retry_clone_with_ok_status(self) -> None:
|
|
"""重试成功,直接返回 OK 状态。"""
|
|
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 = {
|
|
"voice_id": "voice-retry-sync",
|
|
"status": "OK",
|
|
"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
|
|
|
|
|
|
# ── poll_and_process_clone ───────────────────────────────
|
|
|
|
|
|
class TestPollAndProcessClone:
|
|
"""测试 poll_and_process_clone 方法(P2-2 修复)。"""
|
|
|
|
def test_poll_and_process_clone_success(self) -> None:
|
|
"""轮询成功:调用 poll_clone_task → process_clone_result。"""
|
|
mock_repo = MagicMock()
|
|
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
|
|
|
profile = _make_profile(
|
|
status=VoiceCloneStatus.PROCESSING,
|
|
metadata={"cosyvoice_task_id": "task-abc"},
|
|
)
|
|
mock_repo.get.return_value = profile
|
|
mock_repo.update.side_effect = lambda p: p
|
|
|
|
mock_cosyvoice.poll_clone_task.return_value = {"voice_id": "voice-poll-xyz"}
|
|
|
|
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
|
result = service.poll_and_process_clone(profile.id)
|
|
|
|
assert result.status == VoiceCloneStatus.READY
|
|
assert result.voice_id == "voice-poll-xyz"
|
|
mock_cosyvoice.poll_clone_task.assert_called_once_with("task-abc", timeout=300)
|
|
|
|
def test_poll_and_process_clone_no_task_id(self) -> None:
|
|
"""metadata 中没有 task_id 时抛出 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
|
|
|
profile = _make_profile(status=VoiceCloneStatus.PROCESSING, metadata={})
|
|
mock_repo.get.return_value = profile
|
|
|
|
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
|
with pytest.raises(ValueError, match="cosyvoice_task_id"):
|
|
service.poll_and_process_clone(profile.id)
|
|
|
|
def test_poll_and_process_clone_not_found(self) -> None:
|
|
"""profile 不存在时抛出 VoiceCloneNotFoundError。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.get.return_value = None
|
|
|
|
service = _make_service(repo=mock_repo)
|
|
with pytest.raises(VoiceCloneNotFoundError):
|
|
service.poll_and_process_clone("nonexistent")
|
|
|
|
def test_poll_and_process_clone_timeout(self) -> None:
|
|
"""超时时透传 CosyVoiceTimeoutError(由 Celery task 捕获重试)。"""
|
|
from packages.application.cosyvoice_service import CosyVoiceTimeoutError
|
|
|
|
mock_repo = MagicMock()
|
|
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
|
|
|
profile = _make_profile(
|
|
status=VoiceCloneStatus.PROCESSING,
|
|
metadata={"cosyvoice_task_id": "task-abc"},
|
|
)
|
|
mock_repo.get.return_value = profile
|
|
|
|
mock_cosyvoice.poll_clone_task.side_effect = CosyVoiceTimeoutError("超时")
|
|
|
|
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
|
with pytest.raises(CosyVoiceTimeoutError):
|
|
service.poll_and_process_clone(profile.id)
|