Files
xiaoxia-saas/tests/unit/test_confirm_generation.py
xiaoxia e83a0b86f8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (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 / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Check push changed paths (push) Successful in 9s
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 / 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 / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Unit Tests (push) Successful in 8m33s
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 / 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 / Staging E2E Tests (push) Failing after 4m48s
fix(#1749): 批量三连修:配音严格守卫/配音时长分配+末帧冻结/素材级去重 + variant-plans 接口 (#1752)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 09:22:34 +08:00

427 lines
14 KiB
Python
Raw Permalink 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.
"""确认生成 API 单元测试.
覆盖 POST /generation/tasks/{task_id}/confirm 端点:
- 预览任务已完成 → 直接复用(mark_confirmed),秒出
- 预览任务未完成 → 创建新任务走渲染流程
- 预览任务不存在 → 404
- 权限不足 → 403
- cover_url 正确传递
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
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
# ── Stub Repository ──────────────────────────────────────────────────────────
class StubGenerationTaskRepository:
"""内存中模拟 GenerationTask 仓储"""
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:
if task.id not in self._store:
raise ValueError(f"GenerationTask {task.id} not found")
self._store[task.id] = task
return task
def list_by_project(self, project_id: str) -> list[Any]:
return [t for t in self._store.values() if t.project_id == project_id]
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_by_user(self, user_id: str) -> int:
return len([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 list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
items.sort(key=lambda t: t.created_at, reverse=True)
return items[:limit]
def list_by_source_edit_plan(self, plan_id: str) -> list[Any]:
return [t for t in self._store.values() if (t.source_edit_plan_id or "") == plan_id]
# ── Stub Project Repository ──────────────────────────────────────────────────
@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)
# ── Fixtures ─────────────────────────────────────────────────────────────────
@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
@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:
"""构建测试 FastAPI 应用,注入 Stub Repository"""
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_generated_video_repository,
get_generation_task_repository,
get_project_repository,
)
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/generation")
def override_get_current_user():
return FakeAuthenticatedUser()
def override_get_generation_task_repository():
return gen_task_repo
def override_get_project_repository():
return project_repo
test_app.dependency_overrides[get_current_user] = override_get_current_user
test_app.dependency_overrides[get_generation_task_repository] = override_get_generation_task_repository
test_app.dependency_overrides[get_project_repository] = override_get_project_repository
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()
yield test_app
test_app.dependency_overrides.clear()
@pytest.fixture
def client(app: FastAPI) -> TestClient:
return TestClient(app)
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="",
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="",
asset_select_mode="all",
is_preview=True,
source_task_id="",
output_width=1080,
output_height=1920,
cover_url="",
video_title="",
resolution="",
bgm_config={},
)
defaults.update(kwargs)
return GenerationTask(**defaults)
# ── Tests ────────────────────────────────────────────────────────────────────
class TestConfirmGenerationReuse:
"""确认生成复用预览产物。"""
def test_confirm_reuses_completed_preview(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""预览已完成 → 直接复用,返回同一个任务,不创建新任务"""
preview = _make_preview_task()
gen_task_repo.create(preview)
initial_count = len(gen_task_repo._store)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"cover_url": "https://example.com/cover.jpg",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
item = data["items"][0]
# 返回的是同一个任务(复用)
assert item["id"] == preview.id
# is_preview 变为 False
assert item["is_preview"] is False
# 分辨率更新
assert item["output_width"] == 1080
assert item["output_height"] == 1920
# 封面和标题更新
assert item["cover_url"] == "https://example.com/cover.jpg"
# 没有创建新任务
assert len(gen_task_repo._store) == initial_count
def test_confirm_updates_task_in_repo(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""确认后的任务在 repo 中被更新"""
preview = _make_preview_task()
gen_task_repo.create(preview)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"cover_url": "https://cdn.example.com/cover.png", "custom_title": "测试标题"},
)
assert resp.status_code == 200
# 验证 repo 中的任务已被更新
updated = gen_task_repo.get(preview.id)
assert updated is not None
assert updated.is_preview is False
assert updated.cover_url == "https://cdn.example.com/cover.png"
def test_confirm_creates_new_task_when_preview_not_completed(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""预览任务未完成 → 创建新任务走渲染流程"""
preview = _make_preview_task(status=GenerationTaskStatus.RUNNING, progress=50.0)
gen_task_repo.create(preview)
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 1080, "output_height": 1920},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
# 创建了新任务
assert item["id"] != preview.id
assert item["is_preview"] is False
assert item["source_task_id"] == preview.id
class TestConfirmGenerationErrors:
"""确认生成的错误处理。"""
def test_confirm_not_found(self, client: TestClient) -> None:
"""预览任务不存在 → 404"""
resp = client.post(
"/api/v1/generation/tasks/nonexistent-task/confirm",
json={"output_width": 1080, "output_height": 1920},
)
assert resp.status_code == 404
assert "not found" in resp.json()["detail"]
def test_confirm_access_denied(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""权限不足 → 403"""
preview = _make_preview_task(created_by_user_id="other-user-999")
gen_task_repo.create(preview)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 1080, "output_height": 1920},
)
assert resp.status_code == 403
assert "denied" in resp.json()["detail"].lower() or "Access" in resp.json()["detail"]
def test_confirm_preserves_config(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""确认后任务保留预览任务的全部配置"""
preview = _make_preview_task(
voice_library_id="voice-001",
template_id="tmpl-001",
title_ids=["title-1", "title-2"],
voice_ids=["voice-a"],
)
gen_task_repo.create(preview)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 1920, "output_height": 1080},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["is_preview"] is False
assert item["output_width"] == 1920
assert item["output_height"] == 1080
# 默认封面和标题为空
assert item["cover_url"] == ""
# 配置保留
assert item["voice_library_id"] == "voice-001"
assert item["template_id"] == "tmpl-001"
assert item["title_ids"] == ["title-1", "title-2"]
# #1749voice_ids 已废弃(配音走 voice_library_id),确认生成不再保留原值
assert item["voice_ids"] == []
def test_confirm_cover_and_title(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""cover_url 正确传递"""
preview = _make_preview_task()
gen_task_repo.create(preview)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"cover_url": "https://cdn.example.com/my-cover.png",
},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["cover_url"] == "https://cdn.example.com/my-cover.png"
def test_confirm_default_resolution(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""不传分辨率时使用 ConfirmGenerationRequest 默认值 1080x1920"""
preview = _make_preview_task()
gen_task_repo.create(preview)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["output_width"] == 1080
assert item["output_height"] == 1920
def test_confirm_skips_reuse_when_resolution_mismatch(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""请求的分辨率与预览渲染的分辨率不一致时,跳过复用,创建新任务"""
preview = _make_preview_task(output_width=1080, output_height=1920)
gen_task_repo.create(preview)
initial_count = len(gen_task_repo._store)
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 720, "output_height": 1280},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
# 创建了新任务(而非复用)
assert item["id"] != preview.id
assert item["is_preview"] is False
assert item["source_task_id"] == preview.id
assert len(gen_task_repo._store) == initial_count + 1