Files
xiaoxia-saas/tests/unit/test_1970_narrative_service.py
T
xiaoxia 4fa3e4eb92
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 5m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Validate - Security (push) Successful in 6m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m6s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Build Production API Image (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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat(#1970): 新 API 字段 + 叙事模式 PR3 - assembly_mode/script_id/tts_*/video_ratio (#1976)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 07:30:43 +08:00

455 lines
15 KiB
Python
Raw 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.
"""#1970 PR3 叙事前置服务 narrative_service 单元测试(不依赖真实 PG/OSS/CosyVoice)。"""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
import pytest
from apps.api.app.services import narrative_service as ns
from apps.api.app.services.narrative_service import (
NarrativeError,
_resolve_voice,
_save_tts_job_as_voice_asset,
prepare_narrative_voice,
)
from packages.adapters.sqlalchemy_impl.models import ScriptModel
from packages.domain.tts_job import TTSJob, TTSJobStatus
# ── fakes ──────────────────────────────────────────────────────────────────
@dataclass
class FakeProfile:
id: str = "prof-1"
user_id: str = "u1"
voice_id: str = "cv-voice-1"
class FakeCloneRepo:
def __init__(self, profile: FakeProfile | None = None):
self._profile = profile
def get(self, pid: str) -> FakeProfile | None:
if self._profile and self._profile.id == pid:
return self._profile
return None
class FakeQuery:
def __init__(self, script: ScriptModel | None):
self._script = script
def filter(self, *conditions):
# 服务端写 filter(...).filter(...) 链式调用;归属/ID 已在 FakeDb 构造时过滤
return self
def first(self):
return self._script
class FakeDb:
def __init__(self, script: ScriptModel | None, *, current_user: str = "u1", query_script_id: str = "script-1"):
self._script = script
self._current_user = current_user
self._query_script_id = query_script_id
def query(self, model):
visible = self._script
if visible is not None and (visible.user_id != self._current_user or visible.id != self._query_script_id):
visible = None
return FakeQuery(visible)
def _make_script(*, user_id: str = "u1", content: str = "这是一段口播文案", title: str = "测试文案", tags=None):
return ScriptModel(
id="script-1",
user_id=user_id,
title=title,
content=content,
segments=[],
tags=tags if tags is not None else ["带货"],
)
@dataclass
class FakeLibrary:
id: str = "lib-voice"
project_id: str = "p1"
kind: Any = field(default_factory=lambda: SimpleNamespace(value="voice"))
@dataclass
class FakeProject:
id: str = "p1"
class FakeProjectRepo:
def __init__(self, projects=None):
self._projects = projects if projects is not None else [FakeProject()]
def find_accessible_projects(self, user_id):
return self._projects
class FakeLibraryRepo:
def __init__(self, libs=None):
self._libs = libs if libs is not None else [FakeLibrary()]
self.created: list = []
def find_by_project(self, project_id):
return list(self._libs)
def create(self, library):
self.created.append(library)
return library
@dataclass
class FakeAsset:
id: str = "asset-new"
duration: float | None = 12.0
class FakeAssetRepo:
def __init__(self):
self.created: list = []
def create(self, asset):
wrapped = FakeAsset(id="asset-new", duration=getattr(asset, "duration", None))
self.created.append(asset)
return wrapped
class FakeStorage:
def __init__(self, *, fail_download: bool = False):
self.fail_download = fail_download
self.uploaded: list = []
def download_asset(self, source, dest_path) -> bool:
if self.fail_download:
return False
dest_path.write_bytes(b"FAKEAUDIO")
return True
def upload_file(self, path, key, content_type="", **kwargs):
self.uploaded.append((key, content_type))
def delete_file(self, key):
pass
class FakeTTSRepo:
def __init__(self, job: TTSJob):
self.job = job
self.saved: list[TTSJob] = []
def create(self, job: TTSJob) -> TTSJob:
self.saved.append(job)
self.job = job
return job
def update(self, job: TTSJob) -> TTSJob:
self.job = job
return job
def get(self, job_id: str) -> TTSJob | None:
return self.job if self.job.id == job_id else None
class FakeCosyVoice:
pass
def _make_completed_job() -> TTSJob:
job = TTSJob.create(
user_id="u1",
input_text="这是一段口播文案",
voice_id="cv-voice-1",
voice_clone_profile_id="",
format="mp3",
sample_rate=22050,
)
job.mark_processing()
job.mark_completed(
output_audio_url="https://oss/tts/output/job-1.mp3",
output_audio_key="tts/output/job-1.mp3",
duration=12.5,
)
return job
# ── _resolve_voice ─────────────────────────────────────────────────────────
class TestResolveVoice:
def test_preset_returns_id_directly_when_no_profile(self):
voice_id, clone_id = _resolve_voice(
user_id="u1",
tts_voice_id="longxiaochun",
tts_voice_source="preset",
voice_clone_repository=FakeCloneRepo(None),
)
assert voice_id == "longxiaochun"
assert clone_id == ""
def test_preset_id_that_is_clone_profile_uuid_resolves(self):
repo = FakeCloneRepo(FakeProfile())
voice_id, clone_id = _resolve_voice(
user_id="u1",
tts_voice_id="prof-1",
tts_voice_source="preset",
voice_clone_repository=repo,
)
assert voice_id == "cv-voice-1"
assert clone_id == "prof-1"
def test_clone_source(self):
voice_id, clone_id = _resolve_voice(
user_id="u1",
tts_voice_id="prof-1",
tts_voice_source="clone",
voice_clone_repository=FakeCloneRepo(FakeProfile()),
)
assert voice_id == "cv-voice-1"
assert clone_id == "prof-1"
def test_clone_missing_404(self):
with pytest.raises(NarrativeError) as ei:
_resolve_voice(
user_id="u1",
tts_voice_id="nope",
tts_voice_source="clone",
voice_clone_repository=FakeCloneRepo(None),
)
assert ei.value.status_code == 404
def test_clone_other_user_403(self):
repo = FakeCloneRepo(FakeProfile(user_id="someone-else"))
with pytest.raises(NarrativeError) as ei:
_resolve_voice(
user_id="u1",
tts_voice_id="prof-1",
tts_voice_source="clone",
voice_clone_repository=repo,
)
assert ei.value.status_code == 403
def test_clone_not_ready_400(self):
repo = FakeCloneRepo(FakeProfile(voice_id=""))
with pytest.raises(NarrativeError) as ei:
_resolve_voice(
user_id="u1",
tts_voice_id="prof-1",
tts_voice_source="clone",
voice_clone_repository=repo,
)
assert ei.value.status_code == 400
# ── save asset ─────────────────────────────────────────────────────────────
class TestSaveVoiceAsset:
def _deps(self, **storage_kw):
return dict(
user_id="u1",
name="测试配音",
project_repository=FakeProjectRepo(),
asset_library_repository=FakeLibraryRepo(),
asset_repository=FakeAssetRepo(),
storage_service=FakeStorage(**storage_kw),
)
def test_save_creates_asset(self):
job = _make_completed_job()
deps = self._deps()
asset = _save_tts_job_as_voice_asset(job=job, **deps)
assert asset.id == "asset-new"
assert deps["asset_repository"].created[0].mime_type == "audio/mpeg"
assert deps["storage_service"].uploaded[0][0] == "uploads/voice/tts/" + job.id + ".mp3"
def test_no_project_raises(self):
job = _make_completed_job()
deps = self._deps()
deps["project_repository"] = FakeProjectRepo(projects=[])
with pytest.raises(NarrativeError):
_save_tts_job_as_voice_asset(job=job, **deps)
def test_download_fail_raises_502(self):
job = _make_completed_job()
deps = self._deps(fail_download=True)
with pytest.raises(NarrativeError) as ei:
_save_tts_job_as_voice_asset(job=job, **deps)
assert ei.value.status_code == 502
def test_job_without_output_raises(self):
job = TTSJob.create(user_id="u1", input_text="x", voice_id="v", voice_clone_profile_id="")
with pytest.raises(NarrativeError) as ei:
_save_tts_job_as_voice_asset(job=job, **self._deps())
assert ei.value.status_code == 502
# ── prepare_narrative_voice 主流程(monkeypatch workflow) ─────────────────
class TestPrepareNarrativeVoice:
def _deps(self, db_script=None, *, has_script=True, clone_profile=None, storage_fail=False, points_enabled=False):
job = _make_completed_job()
return dict(
db=FakeDb(db_script if db_script is not None else (_make_script() if has_script else None)),
user_id="u1",
script_id="script-1",
tts_voice_id="longxiaochun",
tts_voice_source="preset",
tts_repository=FakeTTSRepo(job),
cosyvoice_service=FakeCosyVoice(),
voice_clone_repository=FakeCloneRepo(clone_profile),
asset_repository=FakeAssetRepo(),
asset_library_repository=FakeLibraryRepo(),
project_repository=FakeProjectRepo(),
storage_service=FakeStorage(fail_download=storage_fail),
points_enabled=points_enabled,
)
def test_success_returns_context(self, monkeypatch):
captured = {}
class FakeWorkflow:
def __init__(self, *, repository, cosyvoice_service):
captured["repo"] = repository
self._repo = repository
def start_synthesis(self, job_id):
job = self._repo.get(job_id)
job.mark_processing()
job.mark_completed(
output_audio_url="https://oss/tts/output/x.mp3",
output_audio_key="tts/output/x.mp3",
duration=12.5,
)
return job
def poll_and_process_synthesis(self, job_id, timeout=120.0):
return self._repo.get(job_id)
monkeypatch.setattr(ns, "TTSWorkflowService", FakeWorkflow)
ctx = prepare_narrative_voice(**self._deps())
assert ctx.voice_asset_id == "asset-new"
assert ctx.tts_job_id
assert ctx.audio_duration == pytest.approx(12.5)
assert ctx.script.tags == ["带货"]
def test_script_missing_404(self):
deps = self._deps(has_script=False)
with pytest.raises(NarrativeError) as ei:
prepare_narrative_voice(**deps)
assert ei.value.status_code == 404
def test_script_other_user_404(self):
deps = self._deps(db_script=_make_script(user_id="other"))
with pytest.raises(NarrativeError) as ei:
prepare_narrative_voice(**deps)
assert ei.value.status_code == 404
def test_empty_content_400(self):
deps = self._deps(db_script=_make_script(content=" "))
with pytest.raises(NarrativeError) as ei:
prepare_narrative_voice(**deps)
assert ei.value.status_code == 400
def test_synth_failure_raises_502(self, monkeypatch):
class FailingWorkflow:
def __init__(self, *, repository, cosyvoice_service):
self._repo = repository
def start_synthesis(self, job_id):
raise RuntimeError("cosyvoice down")
def process_synthesis_failure(self, job_id, error):
return None
monkeypatch.setattr(ns, "TTSWorkflowService", FailingWorkflow)
with pytest.raises(NarrativeError) as ei:
prepare_narrative_voice(**self._deps())
assert ei.value.status_code == 502
assert "配音合成失败" in ei.value.message
def test_points_insufficient_402(self, monkeypatch):
class FakePoints:
def deduct_points(self, *a, **k):
return {"success": False, "balance": 0}
monkeypatch.setattr(ns, "PointsService", lambda: FakePoints())
deps = self._deps(points_enabled=True)
with pytest.raises(NarrativeError) as ei:
prepare_narrative_voice(**deps)
assert ei.value.status_code == 402
def test_points_refund_on_failure(self, monkeypatch):
class FakePoints:
def __init__(self):
self.refunded = 0
def deduct_points(self, *a, **k):
return {"success": True, "balance": 100}
def refund_points(self, user_id, amount, source, db, ref_id="", **k):
self.refunded += amount
points = FakePoints()
monkeypatch.setattr(ns, "PointsService", lambda: points)
class FailingWorkflow:
def __init__(self, *, repository, cosyvoice_service):
pass
def start_synthesis(self, job_id):
raise RuntimeError("boom")
def process_synthesis_failure(self, job_id, error):
return None
monkeypatch.setattr(ns, "TTSWorkflowService", FailingWorkflow)
deps = self._deps(points_enabled=True)
with pytest.raises(NarrativeError):
prepare_narrative_voice(**deps)
assert points.refunded > 0
def test_clone_source_resolves_profile(self, monkeypatch):
captured = {}
class FakeWorkflow:
def __init__(self, *, repository, cosyvoice_service):
self._repo = repository
captured["cosy"] = cosyvoice_service
def start_synthesis(self, job_id):
job = self._repo.get(job_id)
captured["voice_id"] = job.voice_id
job.mark_processing()
job.mark_completed(
output_audio_url="https://oss/tts/output/x.mp3",
output_audio_key="tts/output/x.mp3",
duration=12.5,
)
return job
def poll_and_process_synthesis(self, job_id, timeout=120.0):
return self._repo.get(job_id)
monkeypatch.setattr(ns, "TTSWorkflowService", FakeWorkflow)
deps = self._deps(clone_profile=FakeProfile())
deps["tts_voice_id"] = "prof-1"
deps["tts_voice_source"] = "clone"
prepare_narrative_voice(**deps)
assert captured["voice_id"] == "cv-voice-1"
if __name__ == "__main__":
import pytest as _pytest
_pytest.main([__file__, "-q"])