Files
xiaoxia-saas/tests/unit/test_tts_synthesis_failure.py
CI Bot 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
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
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>
2026-07-13 16:14:46 +08:00

258 lines
9.6 KiB
Python
Raw Permalink 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.
"""TTS 合成失败场景单元测试。
验证 TTS 合成接口在 CosyVoice 失败时返回 201 + failed 状态(与音色克隆一致),
而不是抛 500 错误。
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock
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:
"""CosyVoiceAuthErrorAPI 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:
"""CosyVoiceErrorAPI 调用失败)→ 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