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
380 lines
13 KiB
Python
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
|