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>
314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""save_tts_job_to_library 改写为 assets 素材体系的单元测试(路由级)。
|
|
|
|
覆盖:TTS 音频转存素材 OSS → 创建 audio/ready asset → 返回结构;
|
|
voice 素材库查找/自动创建/并发竞态兜底;未完成/无音频/下载失败等分支。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import 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(timezone.utc),
|
|
"updated_at": datetime.now(timezone.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
|