Files
xiaoxia-saas/tests/unit/test_tts_save_to_library.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

344 lines
12 KiB
Python

"""最后一公里:TTS 合成结果保存到配音库 单元测试。
覆盖:
- 正常保存已完成 TTS job 到配音库
- 自动携带元信息(音色名、时长、语速等)
- 自定义名称
- TTS job 不存在 → 404
- TTS job 未完成 → 400
- 配音库配额已满 → 429
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.domain.voice_library import VoiceLibraryItem
def _make_completed_job(**kwargs) -> TTSJob:
"""构造一个已完成的 TTSJob。"""
defaults = {
"id": "tts_job_001",
"user_id": "user_001",
"input_text": "你好世界",
"voice_id": "voice_001",
"voice_model": "CosyVoice-v1",
"project_id": "proj_001",
"voice_clone_profile_id": "",
"status": TTSJobStatus.COMPLETED,
"output_audio_url": "https://oss.example.com/audio.mp3",
"output_audio_key": "tts-outputs/user_001/tts_job_001.mp3",
"duration": 5.5,
"file_size": 88000,
"sample_rate": 22050,
"format": "mp3",
"error_message": "",
"retry_count": 0,
"max_retries": 3,
"metadata": {"speed": 1.0, "language": "zh-CN"},
"started_at": datetime(2026, 7, 7, 10, 0, 0, tzinfo=timezone.utc),
"completed_at": datetime(2026, 7, 7, 10, 0, 5, tzinfo=timezone.utc),
"created_at": datetime(2026, 7, 7, 10, 0, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 7, 7, 10, 0, 5, tzinfo=timezone.utc),
}
defaults.update(kwargs)
return TTSJob(**defaults)
def _make_voice_library_item(**kwargs) -> VoiceLibraryItem:
"""构造一个配音库条目。"""
defaults = {
"id": "voice_lib_001",
"user_id": "user_001",
"name": "TTS-tts_job_",
"text": "你好世界",
"voice_provider": "cosyvoice",
"voice_id": "voice_001",
"voice_name": "CosyVoice-v1",
"audio_url": "https://oss.example.com/audio.mp3",
"duration": 5.5,
"file_size": 88000,
"status": "completed",
"project_id": "proj_001",
"tags": [],
"metadata_": {
"source": "tts_job",
"tts_job_id": "tts_job_001",
"format": "mp3",
"sample_rate": 22050,
"speed": 1.0,
"language": "zh-CN",
},
"created_at": datetime(2026, 7, 7, 10, 1, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 7, 7, 10, 1, 0, tzinfo=timezone.utc),
}
defaults.update(kwargs)
return VoiceLibraryItem(**defaults)
class TestSaveToLibraryMapping:
"""测试 TTSJob → VoiceLibraryItem 字段映射。"""
def test_completed_job_maps_correctly(self) -> None:
"""已完成的 TTS job 字段正确映射到配音库条目。"""
job = _make_completed_job()
# 验证 is_completed 属性
assert job.is_completed is True
# 验证关键字段映射
assert job.output_audio_url == "https://oss.example.com/audio.mp3"
assert job.duration == 5.5
assert job.file_size == 88000
assert job.voice_id == "voice_001"
assert job.voice_model == "CosyVoice-v1"
assert job.input_text == "你好世界"
assert job.format == "mp3"
assert job.sample_rate == 22050
def test_metadata_carries_speed_and_format(self) -> None:
"""元信息携带语速、格式等。"""
job = _make_completed_job()
metadata = {
"source": "tts_job",
"tts_job_id": job.id,
"format": job.format,
"sample_rate": job.sample_rate,
}
if job.metadata:
for key in ("speed", "language"):
if key in job.metadata:
metadata[key] = job.metadata[key]
assert metadata["format"] == "mp3"
assert metadata["sample_rate"] == 22050
assert metadata["speed"] == 1.0
assert metadata["language"] == "zh-CN"
assert metadata["source"] == "tts_job"
def test_name_auto_generated_when_empty(self) -> None:
"""未提供名称时自动生成。"""
job = _make_completed_job()
name = None # 模拟未提供名称
generated_name = name or f"TTS-{job.id[:8]}"
assert generated_name == "TTS-tts_job_"
def test_name_uses_custom_when_provided(self) -> None:
"""提供自定义名称时使用自定义名称。"""
custom_name = "我的配音"
generated_name = custom_name or "TTS-fallback"
assert generated_name == "我的配音"
class TestSaveToLibraryNotCompleted:
"""测试未完成 job 不能保存。"""
def test_pending_job_not_completed(self) -> None:
"""pending 状态的 job 不能保存。"""
job = _make_completed_job(status=TTSJobStatus.PENDING)
assert job.is_completed is False
def test_processing_job_not_completed(self) -> None:
"""processing 状态的 job 不能保存。"""
job = _make_completed_job(status=TTSJobStatus.PROCESSING)
assert job.is_completed is False
def test_failed_job_not_completed(self) -> None:
"""failed 状态的 job 不能保存。"""
job = _make_completed_job(status=TTSJobStatus.FAILED)
assert job.is_completed is False
def test_completed_without_url_not_completed(self) -> None:
"""status=completed 但没有 audio_url 的 job 不算完成。"""
job = _make_completed_job(
status=TTSJobStatus.COMPLETED,
output_audio_url="",
)
assert job.is_completed is False
class TestSaveToLibraryQuota:
"""测试配额检查。"""
def test_quota_exceeded_raises(self) -> None:
"""配音库配额已满时抛出 QuotaExceededError。"""
from packages.application.voice_library.use_cases import QuotaExceededError
error = QuotaExceededError(dimension="max_voiceovers", limit=10, used=10)
assert "10/10" in str(error)
def test_quota_under_limit_passes(self) -> None:
"""配额未满时不报错。"""
from packages.domain.quota import QuotaDimension, quota_checker
result = quota_checker.check("free", QuotaDimension.MAX_VOICEOVERS.value, 5)
assert result.allowed is True
class TestSaveToLibraryCreateCommand:
"""测试 CreateVoiceLibraryCommand 构建。"""
def test_command_fields_from_tts_job(self) -> None:
"""从 TTSJob 构建的 Command 字段正确。"""
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
job = _make_completed_job()
metadata_ = {
"source": "tts_job",
"tts_job_id": job.id,
"format": job.format,
"sample_rate": job.sample_rate,
"speed": 1.0,
"language": "zh-CN",
}
command = CreateVoiceLibraryCommand(
user_id=job.user_id,
name=f"TTS-{job.id[:8]}",
text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id,
voice_name=job.voice_model,
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed",
project_id=job.project_id,
tags=[],
metadata_=metadata_,
)
assert command.user_id == "user_001"
assert command.name == "TTS-tts_job_"
assert command.text == "你好世界"
assert command.voice_provider == "cosyvoice"
assert command.voice_id == "voice_001"
assert command.voice_name == "CosyVoice-v1"
assert command.audio_url == "https://oss.example.com/audio.mp3"
assert command.duration == 5.5
assert command.file_size == 88000
assert command.status == "completed"
assert command.project_id == "proj_001"
assert command.metadata_["source"] == "tts_job"
def test_command_with_empty_project_id(self) -> None:
"""project_id 为空时传空字符串。"""
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
job = _make_completed_job(project_id="")
command = CreateVoiceLibraryCommand(
user_id=job.user_id,
name="test",
text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id,
voice_name="",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed",
project_id=job.project_id or "",
tags=[],
metadata_={},
)
assert command.project_id == ""
def test_command_voice_name_fallback(self) -> None:
"""voice_model 为空时 voice_name 回退为空字符串。"""
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
job = _make_completed_job(voice_model="")
command = CreateVoiceLibraryCommand(
user_id=job.user_id,
name="test",
text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id,
voice_name=job.voice_model or "",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed",
project_id="",
tags=[],
metadata_={},
)
assert command.voice_name == ""
class TestSaveToLibraryUseCase:
"""测试 CreateVoiceLibraryUseCase 调用。"""
def test_use_case_creates_item(self) -> None:
"""UseCase 正确创建配音库条目。"""
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import CreateVoiceLibraryUseCase
repo = MagicMock()
repo.count_by_user.return_value = 0 # 配额未满
repo.create.side_effect = lambda item: item
use_case = CreateVoiceLibraryUseCase(repo)
command = CreateVoiceLibraryCommand(
user_id="user_001",
name="test",
text="你好",
voice_provider="cosyvoice",
voice_id="v1",
voice_name="Voice1",
audio_url="https://example.com/audio.mp3",
duration=3.0,
file_size=5000,
status="completed",
project_id="",
tags=[],
metadata_={},
)
item = use_case.execute(command, plan_name="free")
repo.create.assert_called_once()
assert item is not None
def test_use_case_quota_exceeded(self) -> None:
"""UseCase 配额已满时抛出 QuotaExceededError。"""
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
repo = MagicMock()
repo.count_by_user.return_value = 100 # 超过 premium 配额
use_case = CreateVoiceLibraryUseCase(repo)
command = CreateVoiceLibraryCommand(
user_id="user_001",
name="test",
text="你好",
voice_provider="cosyvoice",
voice_id="v1",
voice_name="Voice1",
audio_url="https://example.com/audio.mp3",
duration=3.0,
file_size=5000,
status="completed",
project_id="",
tags=[],
metadata_={},
)
with pytest.raises(QuotaExceededError):
use_case.execute(command, plan_name="premium")