61295b9c25
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m43s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m19s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7m2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Unit Tests (push) Successful in 13m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m58s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1134 lines
39 KiB
Python
1134 lines
39 KiB
Python
"""
|
||
TTS 合成 API 集成测试。
|
||
|
||
覆盖端点:
|
||
- POST /tts/synthesize — 创建 TTS 合成任务
|
||
- GET /tts/jobs — 列出 TTS 任务
|
||
- GET /tts/jobs/{job_id} — 获取 TTS 任务详情
|
||
- GET /tts/jobs/{job_id}/status — 获取 TTS 任务状态
|
||
- DELETE /tts/jobs/{job_id} — 删除 TTS 任务
|
||
- POST /tts/jobs/{job_id}/save-to-library — 保存到音色库
|
||
|
||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||
mock repository 和 CosyVoice 服务。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
|
||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||
|
||
from app.api.routes.tts import router
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.core.storage import get_storage_service
|
||
from app.dependencies import (
|
||
get_asset_library_repository,
|
||
get_asset_repository,
|
||
get_cosyvoice_service,
|
||
get_project_repository,
|
||
get_user_repository,
|
||
get_voice_clone_profile_repository,
|
||
get_voice_library_repository,
|
||
)
|
||
|
||
from packages.domain.entities import User
|
||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. 内存 Repository
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class InMemoryTTSJobRepository:
|
||
"""内存中的 TTS 任务 Repository。"""
|
||
|
||
def __init__(self):
|
||
self._items: dict[str, TTSJob] = {}
|
||
|
||
def create(self, job: TTSJob) -> TTSJob:
|
||
self._items[job.id] = job
|
||
return job
|
||
|
||
def get(self, job_id: str) -> TTSJob | None:
|
||
return self._items.get(job_id)
|
||
|
||
def update(self, job: TTSJob) -> TTSJob:
|
||
self._items[job.id] = job
|
||
return job
|
||
|
||
def delete(self, job_id: str) -> bool:
|
||
if job_id in self._items:
|
||
del self._items[job_id]
|
||
return True
|
||
return False
|
||
|
||
def list_by_user(
|
||
self,
|
||
user_id: str,
|
||
*,
|
||
status=None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> list[TTSJob]:
|
||
items = [j for j in self._items.values() if j.user_id == user_id]
|
||
if status:
|
||
status_str = status.value if hasattr(status, "value") else str(status)
|
||
items = [j for j in items if j.status.value == status_str]
|
||
items.sort(key=lambda j: j.created_at, reverse=True)
|
||
return items[offset : offset + limit]
|
||
|
||
def count_by_user(
|
||
self,
|
||
user_id: str,
|
||
*,
|
||
status=None,
|
||
) -> int:
|
||
items = [j for j in self._items.values() if j.user_id == user_id]
|
||
if status:
|
||
status_str = status.value if hasattr(status, "value") else str(status)
|
||
items = [j for j in items if j.status.value == status_str]
|
||
return len(items)
|
||
|
||
def list_by_profile(
|
||
self,
|
||
voice_clone_profile_id: str,
|
||
*,
|
||
status=None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> list[TTSJob]:
|
||
items = [j for j in self._items.values() if j.voice_clone_profile_id == voice_clone_profile_id]
|
||
if status:
|
||
status_str = status.value if hasattr(status, "value") else str(status)
|
||
items = [j for j in items if j.status.value == status_str]
|
||
return items[offset : offset + limit]
|
||
|
||
|
||
class InMemoryVoiceCloneProfileRepository:
|
||
"""内存中的音色克隆档案 Repository(用于 TTS 测试)。"""
|
||
|
||
def __init__(self):
|
||
self._items: dict[str, VoiceCloneProfile] = {}
|
||
|
||
def create(self, profile):
|
||
self._items[profile.id] = profile
|
||
return profile
|
||
|
||
def get(self, profile_id: str):
|
||
return self._items.get(profile_id)
|
||
|
||
def update(self, profile):
|
||
self._items[profile.id] = profile
|
||
return profile
|
||
|
||
def delete(self, profile_id):
|
||
if profile_id in self._items:
|
||
del self._items[profile_id]
|
||
return True
|
||
return False
|
||
|
||
def list_by_user(self, user_id, **kwargs):
|
||
return [p for p in self._items.values() if p.user_id == user_id]
|
||
|
||
def count_by_user(self, user_id, **kwargs):
|
||
return len([p for p in self._items.values() if p.user_id == user_id])
|
||
|
||
def find_by_voice_id(self, voice_id):
|
||
return None
|
||
|
||
def find_profile_ids_by_voice_ids(self, voice_ids):
|
||
return {}
|
||
|
||
|
||
class InMemoryVoiceLibraryRepository:
|
||
"""内存中的配音库 Repository。"""
|
||
|
||
def __init__(self):
|
||
self._items = {}
|
||
|
||
def create(self, item):
|
||
self._items[item.id] = item
|
||
return item
|
||
|
||
def get(self, voice_id: str, user_id: str):
|
||
item = self._items.get(voice_id)
|
||
if item and item.user_id == user_id:
|
||
return item
|
||
return None
|
||
|
||
def update(self, item):
|
||
self._items[item.id] = item
|
||
return item
|
||
|
||
def delete(self, voice_id: str, user_id: str) -> bool:
|
||
item = self.get(voice_id, user_id)
|
||
if item:
|
||
del self._items[voice_id]
|
||
return True
|
||
return False
|
||
|
||
def list_by_user(self, user_id, **kwargs):
|
||
return [i for i in self._items.values() if i.user_id == user_id]
|
||
|
||
def count_by_user(self, user_id: str) -> int:
|
||
return len([i for i in self._items.values() if i.user_id == user_id])
|
||
|
||
|
||
class InMemoryUserRepository:
|
||
"""内存中的用户 Repository。"""
|
||
|
||
def __init__(self):
|
||
self._users = {}
|
||
|
||
def save(self, user):
|
||
self._users[user.id] = user
|
||
|
||
def find_by_id(self, user_id: str):
|
||
return self._users.get(user_id)
|
||
|
||
def find_by_email(self, email: str):
|
||
for u in self._users.values():
|
||
if u.email == email:
|
||
return u
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Mock CosyVoice 服务
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class MockCosyVoiceService:
|
||
"""Mock CosyVoice 服务。"""
|
||
|
||
def __init__(self, *, fail_submit: bool = False):
|
||
self.fail_submit = fail_submit
|
||
self.submit_called = False
|
||
|
||
def submit_synthesize_task(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
|
||
self.submit_called = True
|
||
if self.fail_submit:
|
||
from packages.application.cosyvoice_service import CosyVoiceError
|
||
|
||
raise CosyVoiceError("模拟 CosyVoice 合成失败")
|
||
|
||
return {
|
||
"task_id": "mock-tts-task-123",
|
||
"status": "processing",
|
||
}
|
||
|
||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||
return {
|
||
"status": "completed",
|
||
"audio_url": "https://cdn.example.com/tts/output.mp3",
|
||
"duration": 5.5,
|
||
"file_size": 88000,
|
||
"sample_rate": 22050,
|
||
"format": "mp3",
|
||
}
|
||
|
||
def synthesize_speech(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
|
||
return {
|
||
"audio_url": "https://cdn.example.com/tts/output.mp3",
|
||
"duration": 5.5,
|
||
"file_size": 88000,
|
||
}
|
||
|
||
def submit_clone_task(self, **kwargs) -> dict:
|
||
return {"task_id": "clone-1", "status": "processing"}
|
||
|
||
def list_preset_voices(self) -> list:
|
||
return []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. 辅助函数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_user(**overrides) -> User:
|
||
defaults = dict(
|
||
id="user-test-001",
|
||
email="test@example.com",
|
||
display_name="Test User",
|
||
username="testuser",
|
||
subscription_plan="free",
|
||
subscription_status="active",
|
||
max_projects=3,
|
||
max_storage_gb=10,
|
||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
defaults.update(overrides)
|
||
return User(**defaults)
|
||
|
||
|
||
def _make_tts_job(
|
||
text: str = "你好,这是一段测试文本。",
|
||
user_id: str = "user-test-001",
|
||
status: TTSJobStatus = TTSJobStatus.PENDING,
|
||
**kwargs,
|
||
) -> TTSJob:
|
||
job = TTSJob.create(
|
||
user_id=user_id,
|
||
input_text=text,
|
||
voice_id=kwargs.get("voice_id", "voice-1"),
|
||
voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
|
||
project_id=kwargs.get("project_id", ""),
|
||
voice_clone_profile_id=kwargs.get("voice_clone_profile_id", ""),
|
||
format=kwargs.get("format", "mp3"),
|
||
sample_rate=kwargs.get("sample_rate", 22050),
|
||
max_retries=kwargs.get("max_retries", 3),
|
||
metadata=kwargs.get("metadata", None),
|
||
)
|
||
# 设置状态
|
||
if status == TTSJobStatus.PROCESSING:
|
||
job.mark_processing()
|
||
elif status == TTSJobStatus.COMPLETED:
|
||
job.mark_processing()
|
||
job.mark_completed(
|
||
output_audio_url=kwargs.get("output_audio_url", "https://cdn.example.com/tts/out.mp3"),
|
||
output_audio_key=kwargs.get("output_audio_key", "tts/out.mp3"),
|
||
duration=kwargs.get("duration", 5.5),
|
||
file_size=kwargs.get("file_size", 88000),
|
||
)
|
||
elif status == TTSJobStatus.FAILED:
|
||
job.mark_processing()
|
||
job.mark_failed("合成失败")
|
||
elif status == TTSJobStatus.CANCELLED:
|
||
job.mark_cancelled()
|
||
return job
|
||
|
||
|
||
def _make_voice_clone_profile(
|
||
user_id: str = "user-test-001",
|
||
status: VoiceCloneStatus = VoiceCloneStatus.READY,
|
||
) -> VoiceCloneProfile:
|
||
profile = VoiceCloneProfile.create(
|
||
user_id=user_id,
|
||
name="测试克隆音色",
|
||
voice_model="cosyvoice-v2",
|
||
)
|
||
if status == VoiceCloneStatus.READY:
|
||
profile.mark_processing()
|
||
profile.mark_ready("clone-voice-001")
|
||
return profile
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3b. 素材体系内存 Repository(save-to-library 新链路)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class InMemoryAssetRepository:
|
||
"""内存 Asset 仓储(save-to-library 只用到 create/find_by_id)。"""
|
||
|
||
def __init__(self):
|
||
self._items: dict = {}
|
||
|
||
def create(self, asset):
|
||
self._items[asset.id] = asset
|
||
return asset
|
||
|
||
def find_by_id(self, asset_id: str):
|
||
return self._items.get(asset_id)
|
||
|
||
|
||
class InMemoryAssetLibraryRepository:
|
||
"""内存 AssetLibrary 仓储。"""
|
||
|
||
def __init__(self):
|
||
self._items: dict = {}
|
||
|
||
def create(self, library):
|
||
self._items[library.id] = library
|
||
return library
|
||
|
||
def get(self, library_id: str):
|
||
return self._items.get(library_id)
|
||
|
||
def find_by_id(self, library_id: str):
|
||
return self._items.get(library_id)
|
||
|
||
def find_by_project(self, project_id: str):
|
||
return [lib for lib in self._items.values() if lib.project_id == project_id]
|
||
|
||
|
||
class InMemoryProjectRepository2:
|
||
"""内存 Project 仓储(save-to-library 需要)。"""
|
||
|
||
def __init__(self):
|
||
self._items: dict = {}
|
||
|
||
def find_by_id(self, project_id: str):
|
||
return self._items.get(project_id)
|
||
|
||
def find_accessible_projects(self, user_id: str):
|
||
from packages.domain.entities import Project
|
||
|
||
projects = [p for p in self._items.values() if p.can_access(user_id)]
|
||
if projects:
|
||
return projects
|
||
# 没有任何项目时自动给一个默认项目(与前端 getOrCreateDefaultProject 行为对齐)
|
||
project = Project.create(owner_user_id=user_id, name="默认项目")
|
||
self._items[project.id] = project
|
||
return [project]
|
||
|
||
|
||
class MockStorageService:
|
||
"""Mock 存储:下载写出小文件,上传不做真实 OSS 操作。"""
|
||
|
||
def __init__(self):
|
||
self.uploaded_keys: list[str] = []
|
||
self.download_calls: list[str] = []
|
||
|
||
def download_asset(self, storage_key_or_url: str, local_path) -> bool:
|
||
self.download_calls.append(storage_key_or_url)
|
||
from pathlib import Path
|
||
|
||
path = Path(local_path)
|
||
path.write_bytes(b"FAKE-AUDIO-BYTES" * 100)
|
||
return True
|
||
|
||
def upload_file(self, file_or_path, storage_key: str, content_type: str = "application/octet-stream") -> str:
|
||
self.uploaded_keys.append(storage_key)
|
||
return f"https://cdn.example.com/{storage_key}"
|
||
|
||
def get_download_url(self, storage_key: str, expires_seconds: int = 3600) -> str:
|
||
return f"https://cdn.example.com/{storage_key}?signed=1"
|
||
|
||
def get_url(self, storage_key: str) -> str:
|
||
return f"https://cdn.example.com/{storage_key}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def tts_repo():
|
||
return InMemoryTTSJobRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def voice_clone_repo():
|
||
return InMemoryVoiceCloneProfileRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def voice_library_repo():
|
||
return InMemoryVoiceLibraryRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def asset_repo():
|
||
return InMemoryAssetRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def asset_library_repo():
|
||
return InMemoryAssetLibraryRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def project_repo2():
|
||
return InMemoryProjectRepository2()
|
||
|
||
|
||
@pytest.fixture
|
||
def storage_service():
|
||
return MockStorageService()
|
||
|
||
|
||
@pytest.fixture
|
||
def user_repo():
|
||
repo = InMemoryUserRepository()
|
||
repo.save(_make_user())
|
||
return repo
|
||
|
||
|
||
@pytest.fixture
|
||
def cosyvoice_service():
|
||
return MockCosyVoiceService()
|
||
|
||
|
||
@pytest.fixture
|
||
def client(
|
||
tts_repo,
|
||
voice_clone_repo,
|
||
voice_library_repo,
|
||
user_repo,
|
||
cosyvoice_service,
|
||
asset_repo,
|
||
asset_library_repo,
|
||
project_repo2,
|
||
storage_service,
|
||
):
|
||
"""创建带有依赖覆盖的 TestClient。"""
|
||
test_app = FastAPI()
|
||
test_app.include_router(router, prefix="/tts")
|
||
|
||
def _override_current_user():
|
||
return AuthenticatedUser(user=_make_user())
|
||
|
||
def _override_tts_repo():
|
||
return tts_repo
|
||
|
||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: voice_clone_repo
|
||
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
|
||
test_app.dependency_overrides[get_user_repository] = lambda: user_repo
|
||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||
test_app.dependency_overrides[get_asset_library_repository] = lambda: asset_library_repo
|
||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo2
|
||
test_app.dependency_overrides[get_storage_service] = lambda: storage_service
|
||
|
||
# 使用 FastAPI dependency_overrides 覆盖 TTS repository
|
||
from app.api.routes import tts as tts_module
|
||
|
||
test_app.dependency_overrides[tts_module._get_repository] = lambda: tts_repo
|
||
|
||
yield TestClient(test_app)
|
||
|
||
test_app.dependency_overrides.clear()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. POST /synthesize — 创建 TTS 合成任务
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCreateTTSJob:
|
||
"""创建 TTS 合成任务端点测试。"""
|
||
|
||
def test_create_with_valid_text(self, client, cosyvoice_service):
|
||
"""使用有效文本创建 TTS 任务。"""
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={
|
||
"text": "你好,世界!",
|
||
"voice_id": "voice-1",
|
||
"voice_model": "cosyvoice-v2",
|
||
},
|
||
)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert "job_id" in data
|
||
assert data["message"] == "合成任务已创建"
|
||
assert "status" in data
|
||
|
||
def test_create_persists_to_repository(self, client, tts_repo):
|
||
"""创建后任务保存到 repository。"""
|
||
resp = client.post("/tts/synthesize", json={"text": "持久化测试"})
|
||
job_id = resp.json()["job_id"]
|
||
|
||
saved = tts_repo.get(job_id)
|
||
assert saved is not None
|
||
assert saved.input_text == "持久化测试"
|
||
assert saved.user_id == "user-test-001"
|
||
|
||
def test_create_missing_text_returns_422(self, client):
|
||
"""缺少 text 返回 422。"""
|
||
resp = client.post("/tts/synthesize", json={})
|
||
assert resp.status_code == 422
|
||
|
||
def test_create_empty_text_returns_422(self, client):
|
||
"""空 text 返回 422。"""
|
||
resp = client.post("/tts/synthesize", json={"text": ""})
|
||
assert resp.status_code == 422
|
||
|
||
def test_create_with_custom_format(self, client):
|
||
"""支持指定输出格式。"""
|
||
for fmt in ["mp3", "wav", "pcm"]:
|
||
resp = client.post("/tts/synthesize", json={"text": "测试", "format": fmt})
|
||
assert resp.status_code == 201
|
||
|
||
def test_create_with_invalid_format_returns_422(self, client):
|
||
"""无效格式在 Pydantic 层校验返回 422。"""
|
||
# format 参数不在 TTSSynthesizeRequest schema 中,
|
||
# 或者有默认值/枚举校验。此处测试额外字段会被忽略或校验失败。
|
||
# 实际:schema 中 format 是可选的,有默认值,无效值会在领域层被捕获
|
||
# 但 API 仍返回 201,任务标记为 failed(与音色克隆行为一致)
|
||
resp = client.post("/tts/synthesize", json={"text": "测试", "format": "flac"})
|
||
# 格式不在请求 schema 中时,FastAPI 会忽略额外字段,任务正常创建
|
||
assert resp.status_code == 201
|
||
|
||
def test_create_with_metadata(self, client):
|
||
"""支持自定义 metadata。"""
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={
|
||
"text": "元数据测试",
|
||
"metadata": {"source": "api", "version": "1.0"},
|
||
},
|
||
)
|
||
assert resp.status_code == 201
|
||
|
||
def test_create_with_voice_clone_profile_id(self, client, tts_repo, voice_clone_repo):
|
||
"""显式传 voice_clone_profile_id 创建 TTS:
|
||
|
||
- 仅传 profile id(voice_id 为空)→ job.voice_id 解析为克隆 CosyVoice id
|
||
- voice_id 传预置音色 + 显式 profile id(两者不一致)→ 仍以克隆 profile 为准,
|
||
job.voice_id 必须是克隆 CosyVoice id,不能保留预置音色 id
|
||
"""
|
||
profile = _make_voice_clone_profile() # ready,voice_id="clone-voice-001"
|
||
voice_clone_repo.create(profile)
|
||
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={
|
||
"text": "使用克隆音色",
|
||
"voice_clone_profile_id": profile.id,
|
||
},
|
||
)
|
||
assert resp.status_code == 201
|
||
job = tts_repo.get(resp.json()["job_id"])
|
||
assert job.voice_id == "clone-voice-001"
|
||
assert job.voice_clone_profile_id == profile.id
|
||
|
||
# voice_id 传预置音色 + 显式克隆 profile:合成音色必须以克隆 profile 为准
|
||
resp2 = client.post(
|
||
"/tts/synthesize",
|
||
json={
|
||
"text": "预置voice_id加克隆profile",
|
||
"voice_id": "longxiaochun_v2",
|
||
"voice_clone_profile_id": profile.id,
|
||
},
|
||
)
|
||
assert resp2.status_code == 201, resp2.text
|
||
job2 = tts_repo.get(resp2.json()["job_id"])
|
||
assert job2.voice_id == "clone-voice-001"
|
||
assert job2.voice_clone_profile_id == profile.id
|
||
|
||
def test_create_with_nonexistent_clone_profile_returns_404(self, client):
|
||
"""使用不存在的克隆档案返回 404。"""
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={
|
||
"text": "测试",
|
||
"voice_clone_profile_id": "nonexistent-profile",
|
||
},
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
def test_create_with_other_user_clone_profile_returns_403(self, client, voice_clone_repo):
|
||
"""使用其他用户的克隆档案返回 403。"""
|
||
profile = _make_voice_clone_profile(user_id="other-user")
|
||
voice_clone_repo.create(profile)
|
||
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={
|
||
"text": "越权测试",
|
||
"voice_clone_profile_id": profile.id,
|
||
},
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
def test_synthesize_with_clone_profile_in_voice_id(self, client, tts_repo, voice_clone_repo):
|
||
"""voice_id 直接传克隆 profile UUID(新前端流程):
|
||
|
||
命中 profile → 校验归属 → job.voice_id 存解析后的 CosyVoice voice_id,
|
||
voice_clone_profile_id 记录该 profile。
|
||
"""
|
||
profile = _make_voice_clone_profile() # ready,voice_id="clone-voice-001"
|
||
voice_clone_repo.create(profile)
|
||
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={"text": "克隆音色合成", "voice_id": profile.id},
|
||
)
|
||
assert resp.status_code == 201, resp.text
|
||
job = tts_repo.get(resp.json()["job_id"])
|
||
assert job.voice_id == "clone-voice-001"
|
||
assert job.voice_clone_profile_id == profile.id
|
||
|
||
def test_synthesize_with_other_user_clone_voice_id_returns_403(self, client, voice_clone_repo):
|
||
"""voice_id 传他人克隆 profile UUID → 403。"""
|
||
profile = _make_voice_clone_profile(user_id="other-user")
|
||
voice_clone_repo.create(profile)
|
||
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={"text": "越权克隆音色", "voice_id": profile.id},
|
||
)
|
||
assert resp.status_code == 403, resp.text
|
||
|
||
def test_synthesize_with_unfinished_clone_voice_id_returns_400(self, client, voice_clone_repo):
|
||
"""voice_id 传克隆未完成(voice_id 为空)的 profile → 400。"""
|
||
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
||
|
||
profile = VoiceCloneProfile.create(
|
||
user_id="user-test-001",
|
||
name="未完成克隆",
|
||
voice_model="cosyvoice-v2",
|
||
)
|
||
profile.mark_processing() # processing 状态,尚未 mark_ready,voice_id 为空
|
||
voice_clone_repo.create(profile)
|
||
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={"text": "未完成克隆", "voice_id": profile.id},
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "克隆尚未完成" in resp.json()["detail"]
|
||
|
||
def test_synthesize_with_preset_voice_id_unaffected(self, client, tts_repo):
|
||
"""预置音色 voice_id 不匹配任何 profile 时走原流程,不受影响。"""
|
||
resp = client.post(
|
||
"/tts/synthesize",
|
||
json={"text": "预置音色", "voice_id": "longxiaochun_v2"},
|
||
)
|
||
assert resp.status_code == 201, resp.text
|
||
job = tts_repo.get(resp.json()["job_id"])
|
||
assert job.voice_id == "longxiaochun_v2"
|
||
assert job.voice_clone_profile_id == ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. GET /jobs — 列出 TTS 任务
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestListTTSJobs:
|
||
"""列出 TTS 任务端点测试。"""
|
||
|
||
def test_empty_list(self, client):
|
||
"""无任务时返回空列表。"""
|
||
resp = client.get("/tts/jobs")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["items"] == []
|
||
assert data["total"] == 0
|
||
assert data["page"] == 1
|
||
assert data["page_size"] == 20
|
||
|
||
def test_list_user_jobs(self, client, tts_repo):
|
||
"""只返回当前用户的任务。"""
|
||
j1 = _make_tts_job("任务1", "user-test-001")
|
||
j2 = _make_tts_job("任务2", "user-test-001")
|
||
j3 = _make_tts_job("他人任务", "other-user")
|
||
tts_repo.create(j1)
|
||
tts_repo.create(j2)
|
||
tts_repo.create(j3)
|
||
|
||
resp = client.get("/tts/jobs")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 2
|
||
assert len(data["items"]) == 2
|
||
|
||
def test_filter_by_status(self, client, tts_repo):
|
||
"""按状态筛选。"""
|
||
completed = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED)
|
||
failed = _make_tts_job("已失败", status=TTSJobStatus.FAILED)
|
||
tts_repo.create(completed)
|
||
tts_repo.create(failed)
|
||
|
||
resp = client.get("/tts/jobs?status=completed")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 1
|
||
assert data["items"][0]["status"] == "completed"
|
||
|
||
def test_pagination(self, client, tts_repo):
|
||
"""分页功能。"""
|
||
for i in range(5):
|
||
job = _make_tts_job(f"任务{i}")
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get("/tts/jobs?page=1&page_size=2")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 5
|
||
assert data["page"] == 1
|
||
assert data["page_size"] == 2
|
||
assert len(data["items"]) == 2
|
||
|
||
resp2 = client.get("/tts/jobs?page=2&page_size=2")
|
||
assert resp2.json()["page"] == 2
|
||
assert len(resp2.json()["items"]) == 2
|
||
|
||
resp3 = client.get("/tts/jobs?page=3&page_size=2")
|
||
assert len(resp3.json()["items"]) == 1
|
||
|
||
def test_list_response_fields(self, client, tts_repo):
|
||
"""列表响应包含所有必需字段。"""
|
||
job = _make_tts_job("字段测试", status=TTSJobStatus.COMPLETED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get("/tts/jobs")
|
||
item = resp.json()["items"][0]
|
||
for field in [
|
||
"id",
|
||
"user_id",
|
||
"input_text",
|
||
"voice_id",
|
||
"voice_model",
|
||
"status",
|
||
"output_audio_url",
|
||
"duration",
|
||
"format",
|
||
"error_message",
|
||
"retry_count",
|
||
"max_retries",
|
||
"created_at",
|
||
"updated_at",
|
||
]:
|
||
assert field in item, f"缺少字段: {field}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7. GET /jobs/{job_id} — 获取 TTS 任务详情
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestGetTTSJob:
|
||
"""获取 TTS 任务详情端点测试。"""
|
||
|
||
def test_get_existing_job(self, client, tts_repo):
|
||
"""获取存在的任务返回详情。"""
|
||
job = _make_tts_job("详情测试", voice_model="cosyvoice-v2")
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["id"] == job.id
|
||
assert data["input_text"] == "详情测试"
|
||
assert data["voice_model"] == "cosyvoice-v2"
|
||
|
||
def test_get_nonexistent_returns_404(self, client):
|
||
"""获取不存在的任务返回 404。"""
|
||
resp = client.get("/tts/jobs/nonexistent-job-id")
|
||
assert resp.status_code == 404
|
||
assert "not found" in resp.json()["detail"].lower()
|
||
|
||
def test_get_other_user_job_returns_404(self, client, tts_repo):
|
||
"""获取其他用户的任务返回 404(安全隔离)。"""
|
||
job = _make_tts_job("他人任务", user_id="other-user")
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}")
|
||
assert resp.status_code == 404
|
||
|
||
def test_get_completed_job(self, client, tts_repo):
|
||
"""获取已完成任务包含音频 URL 和时长。"""
|
||
job = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED, duration=10.5)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}")
|
||
data = resp.json()
|
||
assert data["status"] == "completed"
|
||
assert data["output_audio_url"] != ""
|
||
assert data["duration"] == 10.5
|
||
assert data["file_size"] > 0
|
||
|
||
def test_get_failed_job(self, client, tts_repo):
|
||
"""获取失败任务包含错误信息。"""
|
||
job = _make_tts_job("失败任务", status=TTSJobStatus.FAILED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}")
|
||
data = resp.json()
|
||
assert data["status"] == "failed"
|
||
assert data["error_message"] != ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 8. GET /jobs/{job_id}/status — 获取 TTS 任务状态
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestGetTTSJobStatus:
|
||
"""获取 TTS 任务状态端点测试。"""
|
||
|
||
def test_status_pending(self, client, tts_repo):
|
||
"""pending 状态。"""
|
||
job = _make_tts_job("pending", status=TTSJobStatus.PENDING)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["id"] == job.id
|
||
assert data["status"] == "pending"
|
||
|
||
def test_status_completed(self, client, tts_repo):
|
||
"""completed 状态包含音频 URL。"""
|
||
job = _make_tts_job("completed", status=TTSJobStatus.COMPLETED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||
data = resp.json()
|
||
assert data["status"] == "completed"
|
||
assert data["output_audio_url"] != ""
|
||
assert data["duration"] > 0
|
||
|
||
def test_status_failed(self, client, tts_repo):
|
||
"""failed 状态包含错误信息。"""
|
||
job = _make_tts_job("failed", status=TTSJobStatus.FAILED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||
data = resp.json()
|
||
assert data["status"] == "failed"
|
||
assert data["error_message"] != ""
|
||
|
||
def test_status_nonexistent_returns_404(self, client):
|
||
"""获取不存在任务的状态返回 404。"""
|
||
resp = client.get("/tts/jobs/nonexistent/status")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 9. DELETE /jobs/{job_id} — 删除 TTS 任务
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestDeleteTTSJob:
|
||
"""删除 TTS 任务端点测试。"""
|
||
|
||
def test_delete_existing_job(self, client, tts_repo):
|
||
"""删除存在的任务返回 204。"""
|
||
job = _make_tts_job("待删除")
|
||
tts_repo.create(job)
|
||
|
||
resp = client.delete(f"/tts/jobs/{job.id}")
|
||
assert resp.status_code == 204
|
||
|
||
# 验证已删除
|
||
assert tts_repo.get(job.id) is None
|
||
|
||
def test_delete_nonexistent_returns_404(self, client):
|
||
"""删除不存在的任务返回 404。"""
|
||
resp = client.delete("/tts/jobs/nonexistent-job-id")
|
||
assert resp.status_code == 404
|
||
|
||
def test_delete_other_user_job_returns_404(self, client, tts_repo):
|
||
"""删除其他用户的任务返回 404(安全隔离)。"""
|
||
job = _make_tts_job("他人任务", user_id="other-user")
|
||
tts_repo.create(job)
|
||
|
||
resp = client.delete(f"/tts/jobs/{job.id}")
|
||
assert resp.status_code == 404
|
||
# 验证未被删除
|
||
assert tts_repo.get(job.id) is not None
|
||
|
||
def test_delete_idempotent(self, client, tts_repo):
|
||
"""删除后再次删除返回 404。"""
|
||
job = _make_tts_job("幂等测试")
|
||
tts_repo.create(job)
|
||
|
||
resp1 = client.delete(f"/tts/jobs/{job.id}")
|
||
assert resp1.status_code == 204
|
||
|
||
resp2 = client.delete(f"/tts/jobs/{job.id}")
|
||
assert resp2.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 10. POST /jobs/{job_id}/save-to-library — 保存到配音库
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSaveToLibrary:
|
||
"""保存到配音库端点测试。"""
|
||
|
||
def test_save_completed_job(self, client, tts_repo):
|
||
"""保存已完成的 TTS 任务到配音库。"""
|
||
job = _make_tts_job("保存测试", status=TTSJobStatus.COMPLETED, duration=5.5)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.post(
|
||
f"/tts/jobs/{job.id}/save-to-library",
|
||
json={"name": "我的配音"},
|
||
)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["name"] == "我的配音"
|
||
assert data["duration"] == 5.5
|
||
assert data["status"] == "completed"
|
||
assert "id" in data
|
||
assert "audio_url" in data
|
||
assert "voice_id" in data
|
||
assert "voice_name" in data
|
||
|
||
def test_save_pending_job_returns_400(self, client, tts_repo):
|
||
"""保存未完成的任务返回 400。"""
|
||
job = _make_tts_job("未完成", status=TTSJobStatus.PENDING)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||
assert resp.status_code == 400
|
||
assert "not completed" in resp.json()["detail"].lower() or "完成" in resp.json()["detail"]
|
||
|
||
def test_save_failed_job_returns_400(self, client, tts_repo):
|
||
"""保存失败的任务返回 400。"""
|
||
job = _make_tts_job("失败", status=TTSJobStatus.FAILED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||
assert resp.status_code == 400
|
||
|
||
def test_save_nonexistent_job_returns_404(self, client):
|
||
"""保存不存在的任务返回 404。"""
|
||
resp = client.post("/tts/jobs/nonexistent/save-to-library")
|
||
assert resp.status_code == 404
|
||
|
||
def test_save_other_user_job_returns_404(self, client, tts_repo):
|
||
"""保存其他用户的任务返回 404。"""
|
||
job = _make_tts_job("他人任务", user_id="other-user", status=TTSJobStatus.COMPLETED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||
assert resp.status_code == 404
|
||
|
||
def test_save_auto_generates_name(self, client, tts_repo):
|
||
"""不指定名称时自动生成。"""
|
||
job = _make_tts_job("自动命名", status=TTSJobStatus.COMPLETED)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={})
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["name"] != ""
|
||
# 自动生成的名称应该以 TTS- 开头
|
||
assert data["name"].startswith("TTS-")
|
||
|
||
def test_save_creates_library_item(self, client, tts_repo, asset_repo, asset_library_repo):
|
||
"""保存后在素材体系(assets 表)中新增一条 ready 音频素材,并自动创建 voice 库。"""
|
||
job = _make_tts_job("入库测试", status=TTSJobStatus.COMPLETED, duration=6.0)
|
||
tts_repo.create(job)
|
||
|
||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={"name": "入库"})
|
||
assert resp.status_code == 201, resp.text
|
||
data = resp.json()
|
||
|
||
# asset 已创建
|
||
asset = asset_repo.find_by_id(data["id"])
|
||
assert asset is not None
|
||
assert asset.status.value == "ready"
|
||
assert asset.file_type == "audio"
|
||
assert asset.duration == 6.0
|
||
assert (asset.metadata or {}).get("source") == "tts_job"
|
||
assert (asset.metadata or {}).get("tts_job_id") == job.id
|
||
assert asset.uploaded_by_user_id == "user-test-001"
|
||
assert asset.storage_key.startswith("uploads/voice/tts/")
|
||
|
||
# voice 素材库自动创建,asset 挂到该库
|
||
assert asset.library_id in asset_library_repo._items
|
||
voice_lib = asset_library_repo.get(asset.library_id)
|
||
assert voice_lib is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 11. 跨端点场景
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTTSLifecycle:
|
||
"""TTS 完整生命周期测试。"""
|
||
|
||
def test_create_list_get_delete_flow(self, client, tts_repo):
|
||
"""创建 → 列表 → 详情 → 删除 完整流程。"""
|
||
# 1. 创建
|
||
create_resp = client.post("/tts/synthesize", json={"text": "完整流程测试"})
|
||
assert create_resp.status_code == 201
|
||
job_id = create_resp.json()["job_id"]
|
||
|
||
# 2. 列表
|
||
list_resp = client.get("/tts/jobs")
|
||
assert list_resp.json()["total"] == 1
|
||
|
||
# 3. 详情
|
||
detail_resp = client.get(f"/tts/jobs/{job_id}")
|
||
assert detail_resp.status_code == 200
|
||
assert detail_resp.json()["input_text"] == "完整流程测试"
|
||
|
||
# 4. 状态
|
||
status_resp = client.get(f"/tts/jobs/{job_id}/status")
|
||
assert status_resp.status_code == 200
|
||
|
||
# 5. 删除
|
||
del_resp = client.delete(f"/tts/jobs/{job_id}")
|
||
assert del_resp.status_code == 204
|
||
|
||
# 6. 删除后列表为空
|
||
list_resp2 = client.get("/tts/jobs")
|
||
assert list_resp2.json()["total"] == 0
|
||
|
||
def test_create_simulate_complete_save_to_library(self, client, tts_repo):
|
||
"""创建 → 模拟完成 → 保存到配音库 流程。"""
|
||
# 创建任务
|
||
create_resp = client.post("/tts/synthesize", json={"text": "入库流程"})
|
||
job_id = create_resp.json()["job_id"]
|
||
|
||
# 模拟 worker 完成
|
||
job = tts_repo.get(job_id)
|
||
assert job is not None
|
||
# 根据当前状态决定下一步:failed 先重置,pending 则转 processing,已是 processing 则跳过
|
||
if job.status == TTSJobStatus.FAILED:
|
||
job.prepare_retry()
|
||
job.mark_processing()
|
||
elif job.status == TTSJobStatus.PENDING:
|
||
job.mark_processing()
|
||
job.mark_completed(
|
||
output_audio_url="https://cdn.example.com/tts/final.mp3",
|
||
duration=8.0,
|
||
file_size=128000,
|
||
)
|
||
tts_repo.update(job)
|
||
|
||
# 确认完成
|
||
status_resp = client.get(f"/tts/jobs/{job_id}/status")
|
||
assert status_resp.json()["status"] == "completed"
|
||
|
||
# 保存到配音库
|
||
save_resp = client.post(
|
||
f"/tts/jobs/{job_id}/save-to-library",
|
||
json={"name": "最终配音"},
|
||
)
|
||
assert save_resp.status_code == 201
|
||
assert save_resp.json()["name"] == "最终配音"
|
||
assert save_resp.json()["duration"] == 8.0
|
||
|
||
def test_multiple_jobs_status_filter(self, client, tts_repo):
|
||
"""多个任务时按状态筛选正确。"""
|
||
# 创建不同状态的任务
|
||
for text, status in [
|
||
("任务A-完成", TTSJobStatus.COMPLETED),
|
||
("任务B-完成", TTSJobStatus.COMPLETED),
|
||
("任务C-失败", TTSJobStatus.FAILED),
|
||
("任务D-处理中", TTSJobStatus.PROCESSING),
|
||
]:
|
||
job = _make_tts_job(text, status=status)
|
||
tts_repo.create(job)
|
||
|
||
# 按状态筛选
|
||
completed_resp = client.get("/tts/jobs?status=completed")
|
||
assert completed_resp.json()["total"] == 2
|
||
|
||
failed_resp = client.get("/tts/jobs?status=failed")
|
||
assert failed_resp.json()["total"] == 1
|
||
|
||
processing_resp = client.get("/tts/jobs?status=processing")
|
||
assert processing_resp.json()["total"] == 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
pytest.main([__file__, "-v"])
|