""" edit_plans.py 剪辑计划 API 端点单元测试 覆盖(25+ 测试用例): - 创建:正常创建、空名称 400、空 template_id 422 - 列表:默认分页、按状态筛选、按模板筛选、无效状态 400 - 详情:正常获取、不存在 404 - 更新:基础字段更新、状态机合法流转、状态机非法流转 400、不存在 404、无效状态值 400 - 删除:正常删除、不存在 404 """ from __future__ import annotations import os import sys from pathlib import Path from typing import Optional from unittest.mock import MagicMock os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from fastapi import FastAPI from fastapi.testclient import TestClient from packages.domain.config_schemas import normalize_plan_config from packages.domain.edit_plan import EditPlan, EditPlanStatus # --------------------------------------------------------------------------- # Stub Repository # --------------------------------------------------------------------------- class StubEditPlanRepository: """内存中的 EditPlan 仓储 stub""" def __init__(self, plans: dict[str, EditPlan] | None = None): self._plans = plans or {} self._counter = 0 def _next_id(self) -> str: self._counter += 1 return f"plan-{self._counter:03d}" def list_all( self, *, status: Optional[EditPlanStatus] = None, skip: int = 0, limit: int = 50, ) -> list[EditPlan]: items = list(self._plans.values()) if status is not None: 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._plans.values() if p.template_id == template_id] if status is not None: 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._plans.get(plan_id) def create(self, plan: EditPlan) -> EditPlan: self._plans[plan.id] = plan return plan def update(self, plan: EditPlan) -> EditPlan: if plan.id not in self._plans: raise ValueError(f"EditPlan {plan.id} not found") self._plans[plan.id] = plan return plan def delete(self, plan_id: str) -> bool: if plan_id not in self._plans: return False del self._plans[plan_id] return True def delete_by_plan(self, plan_id: str) -> None: """按 plan_id 删除关联片段(stub 实现:无操作)。""" pass def count( self, *, template_id: Optional[str] = None, status: Optional[EditPlanStatus] = None, ) -> int: items = list(self._plans.values()) if template_id: items = [p for p in items if p.template_id == template_id] if status is not None: items = [p for p in items if p.status == status] return len(items) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_auth_user(): """构造 AuthenticatedUser mock""" from app.auth import AuthenticatedUser from packages.domain.entities import User user = User( id="user-001", email="test@example.com", display_name="测试用户", ) return AuthenticatedUser(user=user) def _create_test_app(): """创建带 stub 注入的测试 FastAPI 应用""" import app.services.edit_plan_service as service_module from app.api.routes import edit_plans as edit_plans_module from app.api.routes.edit_plans import router stub_repo = StubEditPlanRepository() # 替换服务模块中的 Repository 类 original_plan_repo_class = service_module.SQLAlchemyEditPlanRepository original_clip_repo_class = service_module.SQLAlchemyEditPlanClipRepository original_generation_task_repo_class = service_module.SQLAlchemyGenerationTaskRepository service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo app = FastAPI() app.include_router(router, prefix="/api/v1/edit-plans") # 覆盖认证依赖 app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock() def cleanup(): service_module.SQLAlchemyEditPlanRepository = original_plan_repo_class service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo_class service_module.SQLAlchemyGenerationTaskRepository = original_generation_task_repo_class return app, stub_repo, cleanup @pytest.fixture def client(): app, stub_repo, cleanup = _create_test_app() yield TestClient(app), stub_repo cleanup() # --------------------------------------------------------------------------- # 创建测试 # --------------------------------------------------------------------------- class TestCreatePlan: def test_create_success(self, client): c, repo = client resp = c.post( "/api/v1/edit-plans", json={ "template_id": "tpl-001", "name": "我的剪辑计划", "config": {"bgm": "happy"}, "total_duration": 60.0, }, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "我的剪辑计划" assert data["template_id"] == "tpl-001" assert data["status"] == "draft" assert data["total_duration"] == 60.0 assert data["config"] == normalize_plan_config({"bgm": "happy"}) assert "id" in data assert "created_at" in data def test_create_minimal(self, client): c, repo = client resp = c.post( "/api/v1/edit-plans", json={"template_id": "tpl-001", "name": "最小计划"}, ) assert resp.status_code == 201 data = resp.json() assert data["config"] == normalize_plan_config(None) assert data["total_duration"] == 0.0 def test_create_empty_name_returns_422(self, client): c, repo = client resp = c.post( "/api/v1/edit-plans", json={"template_id": "tpl-001", "name": ""}, ) assert resp.status_code == 422 def test_create_missing_template_id_returns_422(self, client): c, repo = client resp = c.post( "/api/v1/edit-plans", json={"name": "没有模板的计划"}, ) assert resp.status_code == 422 def test_create_negative_duration_returns_422(self, client): c, repo = client resp = c.post( "/api/v1/edit-plans", json={"template_id": "tpl-001", "name": "test", "total_duration": -1.0}, ) assert resp.status_code == 422 # --------------------------------------------------------------------------- # 列表测试 # --------------------------------------------------------------------------- class TestListPlans: def _seed_plans(self, repo, count=3, template_id="tpl-001"): for i in range(count): plan = EditPlan.create( template_id=template_id, name=f"计划{i+1}", config={"index": i}, ) repo.create(plan) return plan def test_list_empty(self, client): c, repo = client resp = c.get("/api/v1/edit-plans") assert resp.status_code == 200 data = resp.json() assert data["items"] == [] assert data["total"] == 0 assert data["page"] == 1 assert data["page_size"] == 20 def test_list_with_items(self, client): c, repo = client self._seed_plans(repo, count=3) resp = c.get("/api/v1/edit-plans") assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 3 assert data["total"] == 3 def test_list_pagination(self, client): c, repo = client self._seed_plans(repo, count=5) resp = c.get("/api/v1/edit-plans?page=1&page_size=2") assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 2 assert data["total"] == 5 assert data["page"] == 1 resp2 = c.get("/api/v1/edit-plans?page=3&page_size=2") data2 = resp2.json() assert len(data2["items"]) == 1 def test_list_filter_by_status(self, client): c, repo = client p1 = EditPlan.create("tpl-001", "计划A") repo.create(p1) p2 = EditPlan.create("tpl-001", "计划B") repo.create(p2) p2.start_editing() repo.update(p2) resp = c.get("/api/v1/edit-plans?status=draft") data = resp.json() assert data["total"] == 1 assert data["items"][0]["name"] == "计划A" resp2 = c.get("/api/v1/edit-plans?status=editing") data2 = resp2.json() assert data2["total"] == 1 assert data2["items"][0]["name"] == "计划B" def test_list_filter_by_template_id(self, client): c, repo = client p1 = EditPlan.create("tpl-001", "模板1计划") repo.create(p1) p2 = EditPlan.create("tpl-002", "模板2计划") repo.create(p2) resp = c.get("/api/v1/edit-plans?template_id=tpl-001") data = resp.json() assert data["total"] == 1 assert data["items"][0]["name"] == "模板1计划" def test_list_filter_by_template_and_status(self, client): c, repo = client p1 = EditPlan.create("tpl-001", "模板1草稿") repo.create(p1) p2 = EditPlan.create("tpl-001", "模板1编辑中") repo.create(p2) p2.start_editing() repo.update(p2) p3 = EditPlan.create("tpl-002", "模板2草稿") repo.create(p3) resp = c.get("/api/v1/edit-plans?template_id=tpl-001&status=draft") data = resp.json() assert data["total"] == 1 assert data["items"][0]["name"] == "模板1草稿" def test_list_invalid_status_returns_400(self, client): c, repo = client resp = c.get("/api/v1/edit-plans?status=invalid_status") assert resp.status_code == 400 assert "无效" in resp.json()["detail"] # --------------------------------------------------------------------------- # 详情测试 # --------------------------------------------------------------------------- class TestGetPlan: def test_get_success(self, client): c, repo = client plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"}) repo.create(plan) resp = c.get(f"/api/v1/edit-plans/{plan.id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == plan.id assert data["name"] == "测试计划" assert data["config"] == {"key": "val"} def test_get_not_found_returns_404(self, client): c, repo = client resp = c.get("/api/v1/edit-plans/nonexistent-id") assert resp.status_code == 404 assert "剪辑计划不存在" in resp.json()["detail"] # --------------------------------------------------------------------------- # 更新测试 # --------------------------------------------------------------------------- class TestUpdatePlan: def _seed_plan(self, repo, name="原计划", template_id="tpl-001"): plan = EditPlan.create(template_id, name) repo.create(plan) return plan def test_update_name(self, client): c, repo = client plan = self._seed_plan(repo) resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"name": "新名称"}) assert resp.status_code == 200 assert resp.json()["name"] == "新名称" def test_update_config(self, client): c, repo = client plan = self._seed_plan(repo) resp = c.put( f"/api/v1/edit-plans/{plan.id}", json={"config": {"bgm": "sad", "transition": "fade"}}, ) assert resp.status_code == 200 assert resp.json()["config"] == normalize_plan_config({"bgm": "sad", "transition": "fade"}) def test_update_total_duration(self, client): c, repo = client plan = self._seed_plan(repo) resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"total_duration": 120.5}) assert resp.status_code == 200 assert resp.json()["total_duration"] == 120.5 def test_update_status_draft_to_editing(self, client): c, repo = client plan = self._seed_plan(repo) assert plan.status == EditPlanStatus.DRAFT resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"}) assert resp.status_code == 200 assert resp.json()["status"] == "editing" def test_update_status_full_happy_path(self, client): c, repo = client plan = self._seed_plan(repo) # draft → editing resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"}) assert resp.json()["status"] == "editing" # editing → rendering resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"}) assert resp.json()["status"] == "rendering" # rendering → completed resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"}) assert resp.json()["status"] == "completed" def test_update_status_failure_and_reset(self, client): c, repo = client plan = self._seed_plan(repo) # draft → editing → rendering → failed c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"}) c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"}) resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "failed"}) assert resp.json()["status"] == "failed" # failed → draft (reset) resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"}) assert resp.json()["status"] == "draft" def test_update_invalid_transition_returns_400(self, client): c, repo = client plan = self._seed_plan(repo) # draft → rendering 不合法 resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"}) assert resp.status_code == 400 def test_update_draft_to_completed_returns_400(self, client): c, repo = client plan = self._seed_plan(repo) # draft → completed 不合法 resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"}) assert resp.status_code == 400 def test_update_invalid_status_value_returns_400(self, client): c, repo = client plan = self._seed_plan(repo) resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "bogus"}) assert resp.status_code == 400 assert "无效" in resp.json()["detail"] def test_update_same_status_is_noop(self, client): c, repo = client plan = self._seed_plan(repo) resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"}) assert resp.status_code == 200 assert resp.json()["status"] == "draft" def test_update_not_found_returns_404(self, client): c, repo = client resp = c.put("/api/v1/edit-plans/nonexistent", json={"name": "x"}) assert resp.status_code == 404 def test_update_combined_fields_and_status(self, client): c, repo = client plan = self._seed_plan(repo) resp = c.put( f"/api/v1/edit-plans/{plan.id}", json={"name": "新名称", "status": "editing", "total_duration": 90.0}, ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "新名称" assert data["status"] == "editing" assert data["total_duration"] == 90.0 # --------------------------------------------------------------------------- # 删除测试 # --------------------------------------------------------------------------- class TestDeletePlan: def test_delete_success(self, client): c, repo = client plan = EditPlan.create("tpl-001", "待删除") repo.create(plan) resp = c.delete(f"/api/v1/edit-plans/{plan.id}") assert resp.status_code == 204 assert repo.get(plan.id) is None def test_delete_not_found_returns_404(self, client): c, repo = client resp = c.delete("/api/v1/edit-plans/nonexistent") assert resp.status_code == 404 assert "剪辑计划不存在" in resp.json()["detail"]