"""save_tts_job_to_library 改写为 assets 素材体系的单元测试(路由级)。 覆盖:TTS 音频转存素材 OSS → 创建 audio/ready asset → 返回结构; voice 素材库查找/自动创建/并发竞态兜底;未完成/无音频/下载失败等分支。 """ from __future__ import annotations from datetime import UTC, datetime, timezone from unittest.mock import MagicMock from packages.domain import AssetLibrary, AssetLibraryKind from packages.domain.tts_job import TTSJob, TTSJobStatus def _completed_job(**kwargs) -> TTSJob: defaults = { "id": "job-save-001", "user_id": "user-1", "input_text": "测试", "voice_id": "longxiaochun_v2", "voice_model": "", "project_id": "", "voice_clone_profile_id": "", "status": TTSJobStatus.COMPLETED, "output_audio_url": "https://oss.example.com/tts-outputs/u/job.mp3", "output_audio_key": "tts-outputs/user-1/job-save-001.mp3", "duration": 6.0, "file_size": 12345, "sample_rate": 22050, "format": "mp3", "error_message": "", "retry_count": 0, "max_retries": 3, "metadata": {"speed": 1.0}, "started_at": None, "completed_at": None, "created_at": datetime.now(UTC), "updated_at": datetime.now(UTC), } defaults.update(kwargs) return TTSJob(**defaults) class FakeTTSRepo: def __init__(self, job): self._job = job def get(self, job_id, user_id=None): if self._job and self._job.id == job_id and (user_id is None or self._job.user_id == user_id): return self._job return None class FakeAssetRepo: def __init__(self): self.created = [] def create(self, asset): self.created.append(asset) return asset class FakeProject: def __init__(self, pid="proj-1", owner="user-1"): self.id = pid self.owner_user_id = owner def can_access(self, user_id): return user_id == self.owner_user_id class FakeProjectRepo: def __init__(self, projects): self._projects = projects def find_accessible_projects(self, user_id): return [p for p in self._projects if p.can_access(user_id)] def find_by_id(self, pid): for p in self._projects: if p.id == pid: return p return None class FakeAssetLibraryRepo: def __init__(self, libs=None, fail_integrity=False, rollback_raises=False): self._libs = list(libs or []) self.fail_integrity = fail_integrity self.session = MagicMock() if rollback_raises: self.session.rollback.side_effect = RuntimeError("session already closed") def find_by_project(self, project_id): return [lib for lib in self._libs if lib.project_id == project_id] def create(self, library): if self.fail_integrity and not any( lib.project_id == library.project_id and lib.kind == library.kind for lib in self._libs ): from sqlalchemy.exc import IntegrityError # 模拟并发:另一个请求抢先创建了同名库 existing = AssetLibrary.create( project_id=library.project_id, name="配音素材库", kind=AssetLibraryKind.VOICE ) self._libs.append(existing) raise IntegrityError("INSERT", {}, Exception("duplicate key")) self._libs.append(library) return library class FakeStorage: def __init__(self, download_ok=True): self.download_ok = download_ok self.uploads = [] def download_asset(self, source, local_path): if not self.download_ok: return False import os with open(local_path, "wb") as f: f.write(b"FAKEAUDIO" * 100) return os.path.exists(local_path) and os.path.getsize(local_path) > 0 def upload_file(self, local_path, storage_key, content_type=None): self.uploads.append((storage_key, content_type)) return storage_key def delete_file(self, storage_key): self.deleted = getattr(self, "deleted", []) self.deleted.append(storage_key) def get_download_url(self, key, expires_seconds=3600): return f"https://oss.example.com/signed/{key}?sig=xxx" def _build_app(*, job, libs=None, projects=None, storage=None, lib_fail=False, rollback_raises=False): from fastapi import FastAPI app = FastAPI() from app.api.routes.tts import _get_repository, router from app.auth import get_current_user from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_audio_url_signer, get_project_repository, ) app.include_router(router, prefix="/tts") fake_user = MagicMock() fake_user.user.id = "user-1" app.dependency_overrides[get_current_user] = lambda: fake_user app.dependency_overrides[_get_repository] = lambda: FakeTTSRepo(job) asset_repo = FakeAssetRepo() app.dependency_overrides[get_asset_repository] = lambda: asset_repo app.dependency_overrides[get_asset_library_repository] = lambda: FakeAssetLibraryRepo( libs, lib_fail, rollback_raises=rollback_raises ) app.dependency_overrides[get_project_repository] = lambda: FakeProjectRepo( projects if projects is not None else [FakeProject()] ) storage = storage or FakeStorage() app.dependency_overrides[get_storage_service] = lambda: storage app.dependency_overrides[get_audio_url_signer] = lambda: (lambda key: f"https://signed/{key}") return app, asset_repo, storage class TestSaveToLibraryAssets: def test_save_creates_audio_asset_in_voice_library(self): """保存成功:转存音频 + 创建 ready/audio asset,返回结构完整。""" from fastapi.testclient import TestClient voice_lib = AssetLibrary.create(project_id="proj-1", name="配音素材库", kind=AssetLibraryKind.VOICE) app, asset_repo, storage = _build_app(job=_completed_job(), libs=[voice_lib]) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={"name": "我的配音"}) assert resp.status_code == 201, resp.text data = resp.json() assert data["id"] assert data["name"] == "我的配音" assert data["duration"] == 6.0 assert data["voice_id"] == "longxiaochun_v2" assert data["status"] == "completed" assert data["audio_url"].startswith("https://signed/") assert len(asset_repo.created) == 1 asset = asset_repo.created[0] assert asset.file_type == "audio" assert asset.status.value == "ready" assert asset.duration == 6.0 assert asset.storage_key.startswith("uploads/voice/tts/") assert asset.metadata["source"] == "tts_job" assert asset.metadata["tts_job_id"] == "job-save-001" assert asset.uploaded_by_user_id == "user-1" # 音频确实转存到了素材 OSS 路径,且 content_type 正确 assert storage.uploads[0][0] == "uploads/voice/tts/job-save-001.mp3" assert storage.uploads[0][1] == "audio/mpeg" def test_save_auto_creates_voice_library_when_missing(self): """用户有项目但没有 voice 素材库 → 自动创建后挂 asset。""" from fastapi.testclient import TestClient app, asset_repo, _ = _build_app(job=_completed_job(), libs=[], projects=[FakeProject()]) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 201, resp.text asset = asset_repo.created[0] assert asset.project_id == "proj-1" def test_save_concurrent_library_creation_integrity_error(self): """并发建库竞态:create 抛 IntegrityError → 回滚重查返回抢先创建的库。""" from fastapi.testclient import TestClient app, asset_repo, _ = _build_app(job=_completed_job(), libs=[], projects=[FakeProject()], lib_fail=True) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 201, resp.text assert len(asset_repo.created) == 1 def test_save_rejects_uncompleted_job(self): """未完成的 job → 400。""" from fastapi.testclient import TestClient app, _, _ = _build_app(job=_completed_job(status=TTSJobStatus.PROCESSING)) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 400 def test_save_rejects_job_without_audio(self): """已完成但无 output url/key → 400。""" from fastapi.testclient import TestClient app, _, _ = _build_app( job=_completed_job(output_audio_url="", output_audio_key=""), libs=[AssetLibrary.create(project_id="proj-1", name="配音库", kind=AssetLibraryKind.VOICE)], ) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 400 def test_save_download_failure_returns_502(self): """TTS 音频下载失败 → 502,不创建 asset。""" from fastapi.testclient import TestClient voice_lib = AssetLibrary.create(project_id="proj-1", name="配音素材库", kind=AssetLibraryKind.VOICE) app, asset_repo, _ = _build_app(job=_completed_job(), libs=[voice_lib], storage=FakeStorage(download_ok=False)) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 502 assert asset_repo.created == [] def test_save_concurrent_race_tolerates_closed_session(self): """IntegrityError 后 session.rollback() 抛异常(session 已关闭)→ 容错继续重查成功。""" from fastapi.testclient import TestClient app, asset_repo, _ = _build_app( job=_completed_job(), libs=[], projects=[FakeProject()], lib_fail=True, rollback_raises=True ) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 201, resp.text assert len(asset_repo.created) == 1 def test_save_db_failure_cleans_orphan_oss_file(self): """asset_repository.create 抛异常 → 已上传的 OSS 文件被删除,返回 502。""" from fastapi.testclient import TestClient class FailAssetRepo(FakeAssetRepo): def create(self, asset): raise RuntimeError("DB connection lost") voice_lib = AssetLibrary.create(project_id="proj-1", name="配音素材库", kind=AssetLibraryKind.VOICE) storage = FakeStorage() app, _, _ = _build_app(job=_completed_job(), libs=[voice_lib], storage=storage) # 替换 asset repo 为会失败的实现 from app.dependencies import get_asset_repository app.dependency_overrides[get_asset_repository] = lambda: FailAssetRepo() client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 502, resp.text # OSS 文件已上传后又被清理 assert storage.uploads, "音频应已上传" assert hasattr(storage, "deleted") and storage.deleted, "失败后应删除孤儿 OSS 文件" def test_save_no_project_returns_400(self): """用户没有任何可访问项目 → 400。""" from fastapi.testclient import TestClient app, _, _ = _build_app(job=_completed_job(), projects=[]) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 400 def test_save_other_users_job_returns_404(self): """保存他人 job → 404。""" from fastapi.testclient import TestClient app, _, _ = _build_app(job=_completed_job(user_id="other-user")) client = TestClient(app) resp = client.post("/tts/jobs/job-save-001/save-to-library", json={}) assert resp.status_code == 404