Files
xiaoxia-saas/tests/unit/test_create_reuse_preview_fallback.py
T
xiaoxia 7844b65afe
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
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 / Check if frontend-only change (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m40s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m35s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m57s
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 / PR Build Worker Image (pull_request) Successful in 45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m9s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m47s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m45s
AI Code Review / AI Code Review (pull_request) Successful in 8m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m48s
CI/CD Pipeline / Unit Tests (push) Successful in 14m50s
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 / CI Gate (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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m29s
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
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
feat: create端点兜底复用预览产物 + confirm同步标题 (#1486)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-24 23:31:00 +08:00

410 lines
14 KiB
Python

"""create 端点兜底复用预览产物 + confirm 标题同步 — 单元测试."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Optional
from unittest.mock import MagicMock, patch
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 packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ── Stubs ────────────────────────────────────────────────────────────────────
class StubGenerationTaskRepository:
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def create(self, task: Any) -> Any:
self._store[task.id] = task
return task
def get(self, task_id: str) -> Optional[Any]:
return self._store.get(task_id)
def update(self, task: Any) -> Any:
self._store[task.id] = task
return task
def list_by_user(self, user_id: str) -> list[Any]:
return [t for t in self._store.values() if t.created_by_user_id == user_id]
def count_pending_by_user(self, user_id: str) -> int:
return len(
[
t
for t in self._store.values()
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
]
)
def count_pending_total(self) -> int:
return len([t for t in self._store.values() if t.status == GenerationTaskStatus.PENDING])
def count_by_user(self, user_id: str) -> int:
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
@dataclass
class FakeProject:
id: str = "project-001"
owner_user_id: str = "user-001"
shared_users: list[str] = field(default_factory=list)
name: str = "Test Project"
def can_access(self, user_id: str) -> bool:
return user_id == self.owner_user_id or user_id in self.shared_users
class StubProjectRepository:
def __init__(self) -> None:
self._projects: dict[str, FakeProject] = {}
def add(self, project: FakeProject) -> None:
self._projects[project.id] = project
def find_by_id(self, project_id: str) -> Optional[FakeProject]:
return self._projects.get(project_id)
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
def _make_preview_task(**kwargs: Any) -> GenerationTask:
defaults = dict(
id="preview-task-001",
project_id="project-001",
asset_library_id="library-001",
strategy_id="one_take",
voice_library_id="",
template_id="tmpl-001",
asset_ids=["asset-1"],
title_ids=[],
voice_ids=[],
status=GenerationTaskStatus.COMPLETED,
progress=100.0,
result_count=1,
error_message="",
created_by_user_id="user-001",
source_edit_plan_id="plan-001",
asset_select_mode="all",
is_preview=True,
source_task_id="",
output_width=1080,
output_height=1920,
cover_url="",
video_title="",
resolution="",
bgm_config={},
title_config={"text": "预览标题"},
)
defaults.update(kwargs)
return GenerationTask(**defaults)
def _make_db_with_preview(preview: GenerationTask):
"""Create a mock DB that returns a model-like object for the preview."""
db = MagicMock()
mock_model = MagicMock()
mock_model.id = preview.id
mock_model.output_width = preview.output_width
mock_model.output_height = preview.output_height
# Set up chain: db.query(...).filter(...).order_by(...).first()
chain = db.query.return_value
chain.filter.return_value = chain
chain.order_by.return_value = chain
chain.first.return_value = mock_model
return db, mock_model
def _make_db_empty():
"""Create a mock DB that returns None (no preview found)."""
db = MagicMock()
chain = db.query.return_value
chain.filter.return_value = chain
chain.order_by.return_value = chain
chain.first.return_value = None
return db
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
return StubGenerationTaskRepository()
@pytest.fixture
def project_repo() -> StubProjectRepository:
repo = StubProjectRepository()
repo.add(FakeProject())
return repo
@pytest.fixture
def app(
gen_task_repo: StubGenerationTaskRepository,
project_repo: StubProjectRepository,
) -> FastAPI:
from app.api.routes.generation_tasks import router
from app.auth import get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
)
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/generation")
test_app.dependency_overrides[get_current_user] = lambda: FakeAuthenticatedUser()
test_app.dependency_overrides[get_generation_task_repository] = lambda: gen_task_repo
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
# db_session will be overridden per-test
yield test_app
test_app.dependency_overrides.clear()
def _make_client(app: FastAPI, db: MagicMock) -> TestClient:
from app.dependencies import get_db_session
app.dependency_overrides[get_db_session] = lambda: db
return TestClient(app)
# ── Domain: mark_confirmed with title_config ─────────────────────────────────
class TestMarkConfirmedTitleConfig:
def test_mark_confirmed_sets_title_config(self):
task = _make_preview_task()
task.mark_confirmed(title_config={"text": "新标题"})
assert task.is_preview is False
assert task.title_config["text"] == "新标题"
def test_mark_confirmed_without_title_config_preserves_existing(self):
task = _make_preview_task(title_config={"text": "原标题"})
task.mark_confirmed()
assert task.title_config["text"] == "原标题"
# ── Create endpoint fallback ─────────────────────────────────────────────────
class TestCreateEndpointFallback:
"""create 端点兜底复用预览产物。"""
def test_reuse_completed_preview(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""带 source_edit_plan_id + is_preview=False → 复用已完成预览"""
preview = _make_preview_task()
gen_task_repo.create(preview)
db, _ = _make_db_with_preview(preview)
with (
patch(
"packages.adapters.sqlalchemy_impl.generation_task_repository._to_domain",
return_value=preview,
),
patch(
"app.api.routes.generation_tasks._writeback_edit_plan_config",
),
):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": False,
"output_width": 1080,
"output_height": 1920,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] == preview.id
assert data["items"][0]["is_preview"] is False
def test_no_preview_found_creates_new_task(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""没有已完成预览 → 正常创建新任务"""
db = _make_db_empty()
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-002",
"is_preview": False,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] != "preview-task-001"
assert data["items"][0]["is_preview"] is False
def test_preview_request_does_not_use_fallback(
self,
app: FastAPI,
):
"""is_preview=True → 不走兜底,正常创建预览任务"""
db = _make_db_empty()
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": True,
},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["is_preview"] is True
# 兜底查询 GenerationTaskModel 不应被调用(只可能查 EditPlanModel 做自动关联)
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
for call_args in db.query.call_args_list:
assert (
call_args[0][0] is not GenerationTaskModel
), "fallback should not query GenerationTaskModel for preview requests"
def test_fallback_resolution_mismatch_creates_new(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""兜底找到预览但分辨率不一致 → 跳过复用,创建新任务"""
preview = _make_preview_task(output_width=1080, output_height=1920)
gen_task_repo.create(preview)
db, _ = _make_db_with_preview(preview)
with (
patch(
"packages.adapters.sqlalchemy_impl.generation_task_repository._to_domain",
return_value=preview,
),
patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True),
):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": False,
"output_width": 1920,
"output_height": 1080,
},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["id"] != preview.id
# ── Confirm endpoint title sync ──────────────────────────────────────────────
class TestConfirmTitleSync:
"""confirm 端点 custom_title 同步到 title_config。"""
def test_confirm_with_custom_title_updates_title_config(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""传了 custom_title → title_config.text 被更新"""
preview = _make_preview_task(
title_config={"text": "旧标题", "font_size": 32},
source_edit_plan_id="plan-001",
)
gen_task_repo.create(preview)
db = _make_db_empty()
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
client = _make_client(app, db)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"custom_title": "新标题",
},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["title_config"]["text"] == "新标题"
assert item["title_config"]["font_size"] == 32
def test_confirm_without_custom_title_preserves_title(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""没传 custom_title → title_config 不变"""
preview = _make_preview_task(title_config={"text": "原标题"})
gen_task_repo.create(preview)
db = _make_db_empty()
client = _make_client(app, db)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 1080, "output_height": 1920},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["title_config"]["text"] == "原标题"