b4f4d4ffad
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 110h35m20s
CI/CD Pipeline / Frontend Lint (push) Failing after 110h35m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 110h35m29s
Phase 8 引入 normalize_plan_config/normalize_template_config 后, API 会将 config 扩展为完整的结构化默认值(cover/title/subtitle/bgm), 但测试断言仍使用旧的简单 dict 格式,导致 4 个用例失败。 修复方式:导入 normalize_* 函数,断言改为期望标准化后的 config。 修复用例: - test_create_success (plans) - test_create_minimal (plans) - test_update_config (plans) - test_create_with_all_fields (templates)
511 lines
17 KiB
Python
511 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.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"]
|