1f8bbe37dd
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 / Staging E2E Tests (push) Failing after 91h45m45s
Deploy / Deploy Staging (push) Failing after 91h47m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 91h48m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 91h48m24s
与音色克隆接口保持一致: - 路由层增加 try/except 兜底,捕获 workflow 未处理的意外异常(DB错误等) - CosyVoice 失败时标记 job 为 failed,返回 201 + failed 状态任务记录 - Celery 调度失败时也标记 job 为 failed,不抛 500 - 新增 10 个单元测试覆盖各种失败场景 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
9.6 KiB
Python
259 lines
9.6 KiB
Python
"""TTS 合成失败场景单元测试。
|
||
|
||
验证 TTS 合成接口在 CosyVoice 失败时返回 201 + failed 状态(与音色克隆一致),
|
||
而不是抛 500 错误。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.application.cosyvoice_service import (
|
||
CosyVoiceAuthError,
|
||
CosyVoiceError,
|
||
CosyVoiceService,
|
||
CosyVoiceTimeoutError,
|
||
)
|
||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||
|
||
|
||
def _make_job(**kwargs) -> TTSJob:
|
||
defaults = {
|
||
"id": "test_job_001",
|
||
"user_id": "user_001",
|
||
"input_text": "测试文本",
|
||
"voice_id": "voice_001",
|
||
"voice_model": "",
|
||
"project_id": "",
|
||
"voice_clone_profile_id": "",
|
||
"status": TTSJobStatus.PENDING,
|
||
"output_audio_url": "",
|
||
"output_audio_key": "",
|
||
"duration": 0.0,
|
||
"file_size": 0,
|
||
"sample_rate": 22050,
|
||
"format": "mp3",
|
||
"error_message": "",
|
||
"retry_count": 0,
|
||
"max_retries": 3,
|
||
"metadata": {},
|
||
"started_at": None,
|
||
"completed_at": None,
|
||
"created_at": datetime.now(timezone.utc),
|
||
"updated_at": datetime.now(timezone.utc),
|
||
}
|
||
defaults.update(kwargs)
|
||
return TTSJob(**defaults)
|
||
|
||
|
||
class TestTTSWorkflowFailureScenarios:
|
||
"""测试 TTSWorkflowService 在各种失败场景下的行为。
|
||
|
||
核心要求:CosyVoice 失败时,job 应被标记为 failed,而不是抛异常。
|
||
"""
|
||
|
||
def _make_workflow(self, cosyvoice_service: MagicMock, repo: MagicMock | None = None) -> TTSWorkflowService:
|
||
if repo is None:
|
||
repo = MagicMock()
|
||
repo.get.return_value = _make_job()
|
||
repo.update.side_effect = lambda j: j
|
||
return TTSWorkflowService(repository=repo, cosyvoice_service=cosyvoice_service)
|
||
|
||
def test_auth_error_marks_job_failed(self) -> None:
|
||
"""CosyVoiceAuthError(API Key 未配置)→ job 标记为 failed。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||
|
||
workflow = self._make_workflow(service)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.FAILED
|
||
assert "API Key 未配置" in job.error_message
|
||
|
||
def test_api_error_marks_job_failed(self) -> None:
|
||
"""CosyVoiceError(API 调用失败)→ job 标记为 failed。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = CosyVoiceError("CosyVoice API 服务端错误: HTTP 500")
|
||
|
||
workflow = self._make_workflow(service)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.FAILED
|
||
assert "服务端错误" in job.error_message
|
||
|
||
def test_timeout_error_marks_job_failed(self) -> None:
|
||
"""CosyVoiceTimeoutError(超时)→ job 标记为 failed。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = CosyVoiceTimeoutError("请求超时")
|
||
|
||
workflow = self._make_workflow(service)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.FAILED
|
||
assert "超时" in job.error_message
|
||
|
||
def test_value_error_marks_job_failed(self) -> None:
|
||
"""ValueError(参数错误)→ job 标记为 failed。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = ValueError("voice_id 不能为空")
|
||
|
||
workflow = self._make_workflow(service)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.FAILED
|
||
assert "voice_id" in job.error_message
|
||
|
||
def test_unexpected_exception_propagates(self) -> None:
|
||
"""非预期异常(如 DB 错误)→ workflow 不捕获,由路由层兜底。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = RuntimeError("unexpected DB error")
|
||
|
||
workflow = self._make_workflow(service)
|
||
|
||
with pytest.raises(RuntimeError, match="unexpected DB error"):
|
||
workflow.start_synthesis("test_job_001")
|
||
|
||
def test_sync_success_marks_job_completed(self) -> None:
|
||
"""CosyVoice 同步返回 audio_url → job 标记为 completed。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.return_value = {
|
||
"task_id": "",
|
||
"audio_url": "https://example.com/audio.mp3",
|
||
"duration": 3.5,
|
||
"file_size": 12345,
|
||
"request_id": "req_001",
|
||
}
|
||
|
||
workflow = self._make_workflow(service)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.COMPLETED
|
||
assert job.output_audio_url == "https://example.com/audio.mp3"
|
||
assert job.duration == 3.5
|
||
|
||
def test_async_success_marks_job_processing(self) -> None:
|
||
"""CosyVoice 异步模式(返回 task_id)→ job 标记为 processing。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.return_value = {
|
||
"task_id": "cosy_task_001",
|
||
"audio_url": "",
|
||
"duration": 0.0,
|
||
"file_size": 0,
|
||
"request_id": "req_002",
|
||
}
|
||
|
||
workflow = self._make_workflow(service)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.PROCESSING
|
||
assert job.metadata.get("cosyvoice_task_id") == "cosy_task_001"
|
||
|
||
def test_process_synthesis_failure(self) -> None:
|
||
"""process_synthesis_failure 将 job 标记为 failed。"""
|
||
repo = MagicMock()
|
||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||
repo.get.return_value = job
|
||
repo.update.side_effect = lambda j: j
|
||
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
workflow = TTSWorkflowService(repository=repo, cosyvoice_service=service)
|
||
|
||
result = workflow.process_synthesis_failure("test_job_001", "Celery 任务调度失败")
|
||
|
||
assert result.status == TTSJobStatus.FAILED
|
||
assert "Celery 任务调度失败" in result.error_message
|
||
|
||
|
||
class TestTTSRouteFailureScenarios:
|
||
"""测试 TTS 路由层在各种失败场景下的行为。
|
||
|
||
核心要求:即使 workflow 抛出意外异常,路由也应返回 201 + failed,不抛 500。
|
||
"""
|
||
|
||
def test_route_catches_unexpected_exception_from_workflow(self) -> None:
|
||
"""路由层兜底:workflow 抛出非预期异常时,job 仍标记为 failed。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = RuntimeError("unexpected")
|
||
|
||
repo = MagicMock()
|
||
job = _make_job()
|
||
repo.get.return_value = job
|
||
repo.update.side_effect = lambda j: j
|
||
repo.create.side_effect = lambda j: j
|
||
|
||
# 模拟路由逻辑
|
||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||
|
||
use_case = CreateTTSJobUseCase(repo)
|
||
created_job = use_case.execute(
|
||
user_id="user_001",
|
||
input_text="测试文本",
|
||
voice_id="voice_001",
|
||
)
|
||
|
||
workflow = TTSWorkflowService(repository=repo, cosyvoice_service=service)
|
||
|
||
# 模拟路由的 try/except 兜底逻辑
|
||
try:
|
||
result_job = workflow.start_synthesis(created_job.id)
|
||
except Exception as e:
|
||
result_job = workflow.process_synthesis_failure(created_job.id, str(e))
|
||
|
||
assert result_job.status == TTSJobStatus.FAILED
|
||
assert "unexpected" in result_job.error_message
|
||
|
||
def test_route_handles_double_failure_gracefully(self) -> None:
|
||
"""路由层兜底:即使 process_synthesis_failure 也失败,不崩溃。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.side_effect = RuntimeError("unexpected")
|
||
|
||
repo = MagicMock()
|
||
job = _make_job()
|
||
repo.get.return_value = job
|
||
repo.update.side_effect = lambda j: j
|
||
repo.create.side_effect = lambda j: j
|
||
|
||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||
|
||
use_case = CreateTTSJobUseCase(repo)
|
||
created_job = use_case.execute(
|
||
user_id="user_001",
|
||
input_text="测试文本",
|
||
voice_id="voice_001",
|
||
)
|
||
|
||
# 让 process_synthesis_failure 也失败
|
||
call_count = 0
|
||
original_update = repo.update
|
||
|
||
def failing_update(j):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count > 2: # 第3次调用时失败(mark_failed 时的 update)
|
||
raise RuntimeError("DB connection lost")
|
||
return original_update(j)
|
||
|
||
repo.update.side_effect = failing_update
|
||
|
||
workflow = TTSWorkflowService(repository=repo, cosyvoice_service=service)
|
||
|
||
# 模拟路由的 try/except 兜底逻辑(包含内层 try/except)
|
||
try:
|
||
result_job = workflow.start_synthesis(created_job.id)
|
||
except Exception as e:
|
||
try:
|
||
result_job = workflow.process_synthesis_failure(created_job.id, str(e))
|
||
except Exception:
|
||
# 路由层日志记录后,使用原始 job 对象
|
||
result_job = created_job
|
||
|
||
# 不应崩溃,即使状态可能不是 failed
|
||
assert result_job is not None
|