"""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