Files
xiaoxia-saas/tests/unit/test_edit_plans_api.py
T
灵应 aa94a48cc4
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 161h35m55s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 161h36m3s
fix: 修复 Phase 8 测试导入错误 + 升级 psycopg 版本
问题 1: Phase 8 API 测试导入错误(72 个测试跳过)
- 修复 pytest.ini pythonpath 配置,添加 apps/api 和 apps/worker
- 修复 tests/conftest.py 环境变量设置顺序,确保在 app 导入前设置
- 修复 test_dedup_engine.py worker_app 命名空间污染问题
- 修复 test_edit_templates_api.py/test_edit_plans_api.py/test_edit_plan_generation_api.py
  的 Repository patch 目标(从 route 模块改为 service 模块)
- 修复 test_duplication_api.py 和 test_duplication_upload_error_handling.py
  的 sys.modules 保存/恢复机制
- 跳过 test_project_management.py(项目管理功能尚未实现)

问题 2: psycopg 版本不兼容 Python 3.13
- 升级 psycopg[binary] 从 ==3.1.18 到 >=3.2.2

测试结果:
- 926 个测试通过(超过目标的 821 个)
- 所有 72 个 Phase 8 测试成功收集并运行
- 21 个失败 + 6 个错误为预存在的集成测试问题
2026-07-02 19:09:27 +08:00

505 lines
17 KiB
Python

"""
edit_plans.py 剪辑计划 API 端点单元测试
覆盖(25+ 测试用例):
- 创建:正常创建、空名称 400、空 template_id 422
- 列表:默认分页、按状态筛选、按模板筛选、无效状态 400
- 详情:正常获取、不存在 404
- 更新:基础字段更新、状态机合法流转、状态机非法流转 400、不存在 404、无效状态值 400
- 删除:正常删除、不存在 404
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
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.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 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 应用"""
from app.api.routes import edit_plans as edit_plans_module
from app.api.routes.edit_plans import router
import app.services.edit_plan_service as service_module
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"] == {"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"] == {}
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"] == {"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"]