Files
xiaoxia-saas/tests/unit/test_confirm_generation.py
T
CI Bot d8dd510cba
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 / 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 if frontend-only change (pull_request) Successful in 27s
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 (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 36s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-08-08 10:29:24 +00:00

380 lines
13 KiB
Python

"""确认生成 API 单元测试.
覆盖 POST /tasks/{task_id}/confirm 端点:
- 正常确认流程
- 预览任务不存在 → 404
- 权限不足 → 403
- is_preview=False 及分辨率正确
- cover_url 和 custom_title 正确传递
"""
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 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")
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
# Stubs for repositories not used by confirm endpoint but required by router
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=1280,
output_height=720,
cover_url="",
custom_title="",
)
defaults.update(kwargs)
return GenerationTask(**defaults)
# ── Tests ────────────────────────────────────────────────────────────────────
class TestConfirmGeneration:
def test_confirm_success(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""正常确认流程:预览任务存在、权限正确 → 创建正式任务"""
preview = _make_preview_task()
gen_task_repo.create(preview)
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(
f"/api/v1/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"cover_url": "https://example.com/cover.jpg",
"custom_title": "我的视频",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
item = data["items"][0]
assert item["is_preview"] is False
assert item["source_task_id"] == preview.id
assert item["output_width"] == 1080
assert item["output_height"] == 1920
assert item["cover_url"] == "https://example.com/cover.jpg"
assert item["custom_title"] == "我的视频"
# 复制了预览任务的配置
assert item["project_id"] == "project-001"
assert item["asset_library_id"] == "library-001"
assert item["strategy_id"] == "one_take"
assert item["asset_ids"] == ["asset-1"]
# 验证 celery 任务被调度
mock_celery.send_task.assert_called_once()
call_args = mock_celery.send_task.call_args
assert call_args[0][0] == "worker.generate_video"
def test_confirm_not_found(self, client: TestClient) -> None:
"""预览任务不存在 → 404"""
resp = client.post(
"/api/v1/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/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:
"""确认后的任务 is_preview=False,分辨率已更新,其余配置从预览任务复制"""
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)
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(
f"/api/v1/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["source_task_id"] == preview.id
assert item["output_width"] == 1920
assert item["output_height"] == 1080
# 默认分辨率
assert item["cover_url"] == ""
assert item["custom_title"] == ""
# 复制的配置
assert item["voice_library_id"] == "voice-001"
assert item["template_id"] == "tmpl-001"
assert item["title_ids"] == ["title-1", "title-2"]
assert item["voice_ids"] == ["voice-a"]
def test_confirm_cover_and_title(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""cover_url 和 custom_title 正确传递"""
preview = _make_preview_task()
gen_task_repo.create(preview)
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(
f"/api/v1/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"cover_url": "https://cdn.example.com/my-cover.png",
"custom_title": "测试视频标题",
},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["cover_url"] == "https://cdn.example.com/my-cover.png"
assert item["custom_title"] == "测试视频标题"
def test_confirm_default_resolution(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""不传分辨率时使用默认值 1080x1920"""
preview = _make_preview_task()
gen_task_repo.create(preview)
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(
f"/api/v1/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_creates_new_task_in_repo(
self,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""确认生成的任务确实被存入 repository"""
preview = _make_preview_task()
gen_task_repo.create(preview)
initial_count = len(gen_task_repo._store)
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(
f"/api/v1/tasks/{preview.id}/confirm",
json={},
)
assert resp.status_code == 200
new_task_id = resp.json()["items"][0]["id"]
assert new_task_id != preview.id
assert len(gen_task_repo._store) == initial_count + 1
new_task = gen_task_repo.get(new_task_id)
assert new_task is not None
assert new_task.is_preview is False
assert new_task.source_task_id == preview.id