"""剪辑计划生成 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 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.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 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] # ── 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.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.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.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.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.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.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.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