368baf683b
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
746 lines
26 KiB
Python
746 lines
26 KiB
Python
"""剪辑计划生成 API 单元测试 — Phase 8 任务 2.05.
|
||
|
||
覆盖 2 个新端点:
|
||
POST /api/v1/edit-plans/{id}/generate — 触发剪辑渲染生成
|
||
GET /api/v1/edit-plans/{id}/generation-status — 查询生成进度
|
||
|
||
使用 FastAPI TestClient + Stub Repository + dependency_overrides.
|
||
"""
|
||
|
||
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.edit_plan import EditPlan, EditPlanStatus
|
||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||
|
||
# ── Stub Repositories ─────────────────────────────────────────────────────────
|
||
|
||
|
||
class StubEditPlanRepository:
|
||
"""内存中模拟 EditPlan 仓储"""
|
||
|
||
def __init__(self) -> None:
|
||
self._store: dict[str, EditPlan] = {}
|
||
|
||
def list_all(
|
||
self,
|
||
*,
|
||
status: Optional[EditPlanStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> list[EditPlan]:
|
||
items = list(self._store.values())
|
||
if status:
|
||
items = [p for p in items if p.status == status]
|
||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||
return items[skip : skip + limit]
|
||
|
||
def list_by_template(
|
||
self,
|
||
template_id: str,
|
||
*,
|
||
status: Optional[EditPlanStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> list[EditPlan]:
|
||
items = [p for p in self._store.values() if p.template_id == template_id]
|
||
if status:
|
||
items = [p for p in items if p.status == status]
|
||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||
return items[skip : skip + limit]
|
||
|
||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||
return self._store.get(plan_id)
|
||
|
||
def create(self, plan: EditPlan) -> EditPlan:
|
||
self._store[plan.id] = plan
|
||
return plan
|
||
|
||
def update(self, plan: EditPlan) -> EditPlan:
|
||
if plan.id not in self._store:
|
||
raise ValueError(f"EditPlan {plan.id} not found")
|
||
self._store[plan.id] = plan
|
||
return plan
|
||
|
||
def delete(self, plan_id: str) -> bool:
|
||
if plan_id in self._store:
|
||
del self._store[plan_id]
|
||
return True
|
||
return False
|
||
|
||
def count(self, *, status: Optional[EditPlanStatus] = None) -> int:
|
||
items = list(self._store.values())
|
||
if status:
|
||
items = [p for p in items if p.status == status]
|
||
return len(items)
|
||
|
||
|
||
class StubEditPlanClipRepository:
|
||
"""内存中模拟 EditPlanClip 仓储"""
|
||
|
||
def __init__(self) -> None:
|
||
self._store: dict[str, EditPlanClip] = {}
|
||
|
||
def list_by_plan(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
status: Optional[EditPlanClipStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
) -> list[EditPlanClip]:
|
||
items = [c for c in self._store.values() if c.plan_id == plan_id]
|
||
if status:
|
||
items = [c for c in items if c.status == status]
|
||
items.sort(key=lambda c: c.order)
|
||
return items[skip : skip + limit]
|
||
|
||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||
return self._store.get(clip_id)
|
||
|
||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||
self._store[clip.id] = clip
|
||
return clip
|
||
|
||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||
if clip.id not in self._store:
|
||
raise ValueError(f"EditPlanClip {clip.id} not found")
|
||
self._store[clip.id] = clip
|
||
return clip
|
||
|
||
def delete(self, clip_id: str) -> bool:
|
||
if clip_id in self._store:
|
||
del self._store[clip_id]
|
||
return True
|
||
return False
|
||
|
||
def delete_by_plan(self, plan_id: str) -> int:
|
||
to_delete = [c.id for c in self._store.values() if c.plan_id == plan_id]
|
||
for cid in to_delete:
|
||
del self._store[cid]
|
||
return len(to_delete)
|
||
|
||
def count(
|
||
self,
|
||
plan_id: Optional[str] = None,
|
||
*,
|
||
status: Optional[EditPlanClipStatus] = None,
|
||
) -> int:
|
||
items = list(self._store.values())
|
||
if plan_id:
|
||
items = [c for c in items if c.plan_id == plan_id]
|
||
if status:
|
||
items = [c for c in items if c.status == status]
|
||
return len(items)
|
||
|
||
|
||
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 getattr(t, "status", "") == "pending"
|
||
]
|
||
)
|
||
|
||
def count_pending_total(self) -> int:
|
||
return len([t for t in self._store.values() if getattr(t, "status", "") == "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_user_filtered(
|
||
self,
|
||
user_id: str,
|
||
*,
|
||
status: str | None = None,
|
||
limit: int | None = None,
|
||
offset: int = 0,
|
||
) -> list:
|
||
"""按用户+状态筛选任务列表(stub实现)。"""
|
||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||
if status:
|
||
items = [t for t in items if str(t.status) == status]
|
||
# 按创建时间倒序
|
||
items.sort(key=lambda t: t.created_at or "", reverse=True)
|
||
if offset:
|
||
items = items[offset:]
|
||
if limit is not None:
|
||
items = items[:limit]
|
||
return items
|
||
|
||
def count_by_user_filtered(
|
||
self,
|
||
user_id: str,
|
||
*,
|
||
status: str | None = None,
|
||
) -> int:
|
||
"""按用户+状态筛选计数(stub实现)。"""
|
||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||
if status:
|
||
items = [t for t in items if str(t.status) == status]
|
||
return len(items)
|
||
|
||
def list_by_project_filtered(
|
||
self,
|
||
project_id: str,
|
||
*,
|
||
status: str | None = None,
|
||
limit: int | None = None,
|
||
offset: int = 0,
|
||
) -> list:
|
||
"""按项目+状态筛选任务列表(stub实现)。"""
|
||
items = [t for t in self._store.values() if t.project_id == project_id]
|
||
if status:
|
||
items = [t for t in items if str(t.status) == status]
|
||
# 按创建时间倒序
|
||
items.sort(key=lambda t: t.created_at or "", reverse=True)
|
||
if offset:
|
||
items = items[offset:]
|
||
if limit is not None:
|
||
items = items[:limit]
|
||
return items
|
||
|
||
def count_by_project_filtered(
|
||
self,
|
||
project_id: str,
|
||
*,
|
||
status: str | None = None,
|
||
) -> int:
|
||
"""按项目+状态筛选计数(stub实现)。"""
|
||
items = [t for t in self._store.values() if t.project_id == project_id]
|
||
if status:
|
||
items = [t for t in items if str(t.status) == status]
|
||
return len(items)
|
||
|
||
|
||
# ── 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 plan_repo() -> StubEditPlanRepository:
|
||
return StubEditPlanRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def clip_repo() -> StubEditPlanClipRepository:
|
||
return StubEditPlanClipRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def gen_task_repo() -> StubGenerationTaskRepository:
|
||
return StubGenerationTaskRepository()
|
||
|
||
|
||
@pytest.fixture
|
||
def app(
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
gen_task_repo: StubGenerationTaskRepository,
|
||
) -> FastAPI:
|
||
"""构建测试 FastAPI 应用,注入 Stub Repository"""
|
||
import app.services.edit_plan_service as service_module
|
||
from app.api.routes.edit_plans import router
|
||
from app.auth import get_current_user
|
||
from app.dependencies import get_db_session
|
||
|
||
# 替换 Repository 类
|
||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||
|
||
service_module.SQLAlchemyEditPlanRepository = lambda session: plan_repo
|
||
service_module.SQLAlchemyEditPlanClipRepository = lambda session: clip_repo
|
||
service_module.SQLAlchemyGenerationTaskRepository = lambda session: gen_task_repo
|
||
|
||
test_app = FastAPI()
|
||
test_app.include_router(router, prefix="/api/v1/edit-plans")
|
||
|
||
def override_get_db_session():
|
||
yield MagicMock()
|
||
|
||
def override_get_current_user():
|
||
return FakeAuthenticatedUser()
|
||
|
||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||
|
||
yield test_app
|
||
|
||
# 恢复
|
||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||
test_app.dependency_overrides.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def client(app: FastAPI) -> TestClient:
|
||
return TestClient(app)
|
||
|
||
|
||
def _make_plan(
|
||
name: str = "测试计划",
|
||
template_id: str = "tmpl-001",
|
||
status: EditPlanStatus = EditPlanStatus.DRAFT,
|
||
**kwargs: Any,
|
||
) -> EditPlan:
|
||
plan = EditPlan.create(template_id=template_id, name=name, **kwargs)
|
||
plan.status = status
|
||
return plan
|
||
|
||
|
||
def _make_clip(
|
||
plan_id: str,
|
||
clip_type: str = "MAIN",
|
||
order: int = 1,
|
||
asset_id: str = "assets/video.mp4",
|
||
status: EditPlanClipStatus = EditPlanClipStatus.PENDING,
|
||
**kwargs: Any,
|
||
) -> EditPlanClip:
|
||
clip = EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=clip_type,
|
||
order=order,
|
||
asset_id=asset_id,
|
||
**kwargs,
|
||
)
|
||
clip.status = status
|
||
return clip
|
||
|
||
|
||
# ── POST /api/v1/edit-plans/{id}/generate ─────────────────────────────────────
|
||
|
||
|
||
class TestGeneratePlan:
|
||
def test_generate_success(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""editing 状态 + pending 片段 → 触发成功"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
mock_celery.send_task = MagicMock()
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_id"] == plan.id
|
||
assert data["plan_status"] == "rendering"
|
||
assert data["clip_count"] == 1
|
||
assert "generation_task_id" in data
|
||
|
||
# 验证计划状态已更新
|
||
updated = plan_repo.get(plan.id)
|
||
assert updated.status == EditPlanStatus.RENDERING
|
||
|
||
# 验证片段状态已更新为 ready
|
||
updated_clip = clip_repo.get(clip.id)
|
||
assert updated_clip.status == EditPlanClipStatus.READY
|
||
|
||
def test_generate_not_found(self, client: TestClient) -> None:
|
||
"""计划不存在 → 404"""
|
||
resp = client.post("/api/v1/edit-plans/nonexistent/generate")
|
||
assert resp.status_code == 404
|
||
assert "不存在" in resp.json()["detail"]
|
||
|
||
def test_generate_draft_auto_transition_to_editing(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
) -> None:
|
||
"""draft 状态自动转 editing(自动兜底),然后因 0 片段报错"""
|
||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||
plan_repo.create(plan)
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
# draft 自动转 editing,但没有片段所以还是 400
|
||
assert resp.status_code == 400
|
||
assert "请先添加片段后再生成视频" in resp.json()["detail"]
|
||
# 验证状态已自动转为 editing
|
||
updated_plan = plan_repo.get(plan.id)
|
||
assert updated_plan is not None
|
||
assert updated_plan.status == EditPlanStatus.EDITING
|
||
|
||
def test_generate_wrong_status_rendering(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
) -> None:
|
||
"""rendering 状态 → 400"""
|
||
plan = _make_plan(status=EditPlanStatus.RENDERING)
|
||
plan_repo.create(plan)
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
assert resp.status_code == 400
|
||
|
||
def test_generate_wrong_status_completed(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
) -> None:
|
||
"""completed 状态 → 400"""
|
||
plan = _make_plan(status=EditPlanStatus.COMPLETED)
|
||
plan_repo.create(plan)
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
assert resp.status_code == 400
|
||
|
||
def test_generate_no_clips(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
) -> None:
|
||
"""editing 状态但没有片段 → 400"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
assert resp.status_code == 400
|
||
assert "片段" in resp.json()["detail"]
|
||
|
||
def test_generate_with_ready_clips(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""已有 ready 状态的片段也可以触发生成"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
mock_celery.send_task = MagicMock()
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_status"] == "rendering"
|
||
|
||
def test_generate_multiple_clips(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""多个片段全部从 pending 转为 ready"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
for i in range(3):
|
||
clip = _make_clip(plan.id, order=i + 1)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
mock_celery.send_task = MagicMock()
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["clip_count"] == 3
|
||
|
||
# 验证所有片段都变为 ready
|
||
clips = clip_repo.list_by_plan(plan.id)
|
||
assert all(c.status == EditPlanClipStatus.READY for c in clips)
|
||
|
||
|
||
# ── GET /api/v1/edit-plans/{id}/generation-status ─────────────────────────────
|
||
|
||
|
||
class TestGetGenerationStatus:
|
||
def test_status_not_found(self, client: TestClient) -> None:
|
||
"""计划不存在 → 404"""
|
||
resp = client.get("/api/v1/edit-plans/nonexistent/generation-status")
|
||
assert resp.status_code == 404
|
||
|
||
def test_status_draft_plan(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""draft 状态计划的生成状态"""
|
||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1)
|
||
clip_repo.create(clip)
|
||
|
||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_id"] == plan.id
|
||
assert data["plan_status"] == "draft"
|
||
assert data["generation_task_id"] is None
|
||
assert len(data["clips"]) == 1
|
||
assert data["clips"][0]["status"] == "pending"
|
||
|
||
def test_status_rendering_plan(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""rendering 状态计划的生成状态"""
|
||
plan = _make_plan(status=EditPlanStatus.RENDERING)
|
||
plan.config["generation_task_id"] = "gen-task-001"
|
||
plan_repo.create(plan)
|
||
|
||
clip1 = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
|
||
clip2 = _make_clip(plan.id, order=2, status=EditPlanClipStatus.READY)
|
||
clip_repo.create(clip1)
|
||
clip_repo.create(clip2)
|
||
|
||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_status"] == "rendering"
|
||
assert data["generation_task_id"] == "gen-task-001"
|
||
assert len(data["clips"]) == 2
|
||
statuses = {c["status"] for c in data["clips"]}
|
||
assert "rendered" in statuses
|
||
assert "ready" in statuses
|
||
|
||
def test_status_completed_plan(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""completed 状态计划的生成状态"""
|
||
plan = _make_plan(status=EditPlanStatus.COMPLETED)
|
||
plan.config["generation_task_id"] = "gen-task-002"
|
||
plan.config["rendered_url"] = "https://oss.example.com/rendered/output.mp4"
|
||
plan_repo.create(plan)
|
||
|
||
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
|
||
clip_repo.create(clip)
|
||
|
||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_status"] == "completed"
|
||
assert data["generation_task_id"] == "gen-task-002"
|
||
assert len(data["clips"]) == 1
|
||
assert data["clips"][0]["status"] == "rendered"
|
||
|
||
def test_status_clip_fields(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""验证片段状态返回的字段完整性"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(
|
||
plan.id,
|
||
clip_type="INTRO",
|
||
order=1,
|
||
asset_id="assets/intro.mp4",
|
||
duration=5.0,
|
||
)
|
||
clip.text_content = "欢迎观看"
|
||
clip_repo.create(clip)
|
||
|
||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
clip_data = data["clips"][0]
|
||
assert clip_data["clip_id"] == clip.id
|
||
assert clip_data["clip_type"] == "INTRO"
|
||
assert clip_data["order"] == 1
|
||
assert clip_data["asset_id"] == "assets/intro.mp4"
|
||
assert clip_data["text_content"] == "欢迎观看"
|
||
assert clip_data["duration"] == 5.0
|
||
|
||
def test_status_no_clips(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
) -> None:
|
||
"""没有片段的计划也能查询状态"""
|
||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||
plan_repo.create(plan)
|
||
|
||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["clips"] == []
|
||
|
||
|
||
# ── Response Schema 验证 ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestResponseSchema:
|
||
def test_generate_response_structure(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""验证 generate 端点响应结构"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
mock_celery.send_task = MagicMock()
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clip_count"}
|
||
assert set(data.keys()) == expected_keys
|
||
|
||
def test_generation_status_response_structure(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
) -> None:
|
||
"""验证 generation-status 端点响应结构"""
|
||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||
plan_repo.create(plan)
|
||
|
||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
|
||
assert set(data.keys()) == expected_keys
|
||
|
||
|
||
# ── P0-1: 生成接口错误处理 ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGeneratePlanErrorHandling:
|
||
"""P0-1: generate 端点异常时返回明确错误信息,不裸 500"""
|
||
|
||
def test_generate_internal_error_returns_clear_message(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""核心流程抛异常 → 500 + 用户友好的错误信息(不暴露技术细节)"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
# 模拟 Celery 调度失败
|
||
mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时")
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 500
|
||
data = resp.json()
|
||
# 验证返回了用户友好的错误信息,不暴露技术细节
|
||
assert "生成失败" in data["detail"]
|
||
assert "RuntimeError" not in data["detail"]
|
||
assert "Redis" not in data["detail"]
|
||
|
||
def test_generate_error_rolls_back_plan_status(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""异常时将计划标记为 failed(RENDERING → FAILED 是合法流转)"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
mock_celery.send_task.side_effect = RuntimeError("调度失败")
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 500
|
||
# 计划状态应变为 failed
|
||
updated = plan_repo.get(plan.id)
|
||
assert updated.status == EditPlanStatus.FAILED
|
||
|
||
def test_generate_error_detail_is_user_friendly(
|
||
self,
|
||
client: TestClient,
|
||
plan_repo: StubEditPlanRepository,
|
||
clip_repo: StubEditPlanClipRepository,
|
||
) -> None:
|
||
"""错误信息对用户友好,不暴露技术细节(异常类型、内部错误信息)"""
|
||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||
plan_repo.create(plan)
|
||
clip = _make_clip(plan.id, order=1)
|
||
clip_repo.create(clip)
|
||
|
||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||
mock_celery.send_task.side_effect = ConnectionError("Broker 不可达")
|
||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||
|
||
assert resp.status_code == 500
|
||
detail = resp.json()["detail"]
|
||
# 验证不暴露技术细节
|
||
assert "ConnectionError" not in detail
|
||
assert "Broker 不可达" not in detail
|
||
# 验证返回了用户友好的提示
|
||
assert "生成失败" in detail
|