53c6e66eaa
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 44s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m45s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 9m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 10m16s
- EditPlanCreateRequest增加asset_ids字段 - create_plan调用generate_from_template时传递body.asset_ids而非硬编码空数组 - asset_ids同时写入plan.config,供generate时兜底分配 - 新增test_create_with_asset_ids测试验证参数传递
854 lines
29 KiB
Python
Executable File
854 lines
29 KiB
Python
Executable File
"""
|
|
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, patch
|
|
|
|
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:
|
|
"""创建剪辑计划测试。
|
|
|
|
注意:创建计划时会从模板生成 clips,这里 mock 掉模板服务和生成器,
|
|
专注验证 API 层参数传递和响应格式。
|
|
"""
|
|
|
|
def _make_test_plan(self, plan_id="plan-001", template_id="tpl-001", name="测试计划"):
|
|
"""构造一个测试用 EditPlan"""
|
|
return EditPlan.create(
|
|
template_id=template_id,
|
|
name=name,
|
|
config=normalize_plan_config({}),
|
|
total_duration=15.0,
|
|
)
|
|
|
|
@patch("app.api.routes.edit_plans.EditTemplateService")
|
|
@patch("app.services.PlanGeneratorService")
|
|
def test_create_success(self, mock_generator_cls, mock_template_svc_cls, client):
|
|
c, repo = client
|
|
|
|
# Setup mock 模板服务
|
|
mock_template_svc = MagicMock()
|
|
mock_template_svc.get_template_or_raise.return_value = MagicMock(
|
|
id="tpl-001",
|
|
name="测试模板",
|
|
config={},
|
|
)
|
|
mock_template_svc.list_clip_configs.return_value = []
|
|
mock_template_svc_cls.return_value = mock_template_svc
|
|
|
|
# Setup mock 生成器
|
|
mock_gen = MagicMock()
|
|
test_plan = self._make_test_plan(name="我的剪辑计划")
|
|
test_plan.status = EditPlanStatus.EDITING
|
|
# 把 plan 存到 stub repo,这样后续 update_plan 能找到
|
|
repo.create(test_plan)
|
|
mock_gen.generate_from_template.return_value = {
|
|
"plan": test_plan,
|
|
"clips": [],
|
|
}
|
|
mock_generator_cls.return_value = mock_gen
|
|
|
|
resp = c.post(
|
|
"/api/v1/edit-plans",
|
|
json={
|
|
"template_id": "tpl-001",
|
|
"name": "我的剪辑计划",
|
|
"config": {"bgm": {"enabled": True}},
|
|
"total_duration": 60.0,
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["name"] == "我的剪辑计划"
|
|
assert data["template_id"] == "tpl-001"
|
|
assert "id" in data
|
|
assert "created_at" in data
|
|
|
|
# 验证调用了生成器
|
|
mock_gen.generate_from_template.assert_called_once()
|
|
call_kwargs = mock_gen.generate_from_template.call_args[1]
|
|
assert call_kwargs["template"].id == "tpl-001"
|
|
assert call_kwargs["name"] == "我的剪辑计划"
|
|
assert call_kwargs["created_by_user_id"] == "user-001"
|
|
|
|
@patch("app.api.routes.edit_plans.EditTemplateService")
|
|
@patch("app.services.PlanGeneratorService")
|
|
def test_create_with_asset_ids(self, mock_generator_cls, mock_template_svc_cls, client):
|
|
"""创建计划时传入 asset_ids,应传递给生成器并写入 plan.config"""
|
|
c, repo = client
|
|
|
|
mock_template_svc = MagicMock()
|
|
mock_template_svc.get_template_or_raise.return_value = MagicMock(
|
|
id="tpl-001",
|
|
name="测试模板",
|
|
config={},
|
|
)
|
|
mock_template_svc.list_clip_configs.return_value = []
|
|
mock_template_svc_cls.return_value = mock_template_svc
|
|
|
|
mock_gen = MagicMock()
|
|
test_plan = self._make_test_plan(name="带素材计划")
|
|
test_plan.status = EditPlanStatus.EDITING
|
|
repo.create(test_plan)
|
|
mock_gen.generate_from_template.return_value = {
|
|
"plan": test_plan,
|
|
"clips": [],
|
|
}
|
|
mock_generator_cls.return_value = mock_gen
|
|
|
|
asset_ids = ["asset-001", "asset-002", "asset-003"]
|
|
resp = c.post(
|
|
"/api/v1/edit-plans",
|
|
json={
|
|
"template_id": "tpl-001",
|
|
"name": "带素材计划",
|
|
"asset_ids": asset_ids,
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
# 验证 asset_ids 传递给了生成器
|
|
mock_gen.generate_from_template.assert_called_once()
|
|
call_kwargs = mock_gen.generate_from_template.call_args[1]
|
|
assert call_kwargs["asset_ids"] == asset_ids
|
|
|
|
@patch("app.api.routes.edit_plans.EditTemplateService")
|
|
@patch("app.services.PlanGeneratorService")
|
|
def test_create_minimal(self, mock_generator_cls, mock_template_svc_cls, client):
|
|
c, repo = client
|
|
|
|
mock_template_svc = MagicMock()
|
|
mock_template_svc.get_template_or_raise.return_value = MagicMock(
|
|
id="tpl-001",
|
|
name="测试模板",
|
|
config={},
|
|
)
|
|
mock_template_svc.list_clip_configs.return_value = []
|
|
mock_template_svc_cls.return_value = mock_template_svc
|
|
|
|
mock_gen = MagicMock()
|
|
test_plan = self._make_test_plan()
|
|
test_plan.status = EditPlanStatus.EDITING
|
|
# 把 plan 存到 stub repo
|
|
repo.create(test_plan)
|
|
mock_gen.generate_from_template.return_value = {
|
|
"plan": test_plan,
|
|
"clips": [],
|
|
}
|
|
mock_generator_cls.return_value = mock_gen
|
|
|
|
resp = c.post(
|
|
"/api/v1/edit-plans",
|
|
json={"template_id": "tpl-001", "name": "最小计划"},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert "id" in data
|
|
|
|
# 验证生成器被调用
|
|
mock_gen.generate_from_template.assert_called_once()
|
|
|
|
@patch("app.api.routes.edit_plans.EditTemplateService")
|
|
def test_create_template_not_found_falls_back_empty(self, mock_template_svc_cls, client):
|
|
"""模板不存在时降级为空计划(向后兼容)"""
|
|
c, repo = client
|
|
|
|
mock_template_svc = MagicMock()
|
|
mock_template_svc.get_template_or_raise.side_effect = ValueError("模板不存在")
|
|
mock_template_svc_cls.return_value = mock_template_svc
|
|
|
|
resp = c.post(
|
|
"/api/v1/edit-plans",
|
|
json={"template_id": "nonexistent", "name": "测试"},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["name"] == "测试"
|
|
assert data["template_id"] == "nonexistent"
|
|
# 空计划没有片段
|
|
assert "clips" not in data or len(data.get("clips", [])) == 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"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BGM 配置测试
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBGMConfig:
|
|
"""BGM 配置 API 测试"""
|
|
|
|
def test_get_bgm_default_empty(self, client):
|
|
"""新计划 BGM 默认为空"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
repo.create(plan)
|
|
|
|
resp = c.get(f"/api/v1/edit-plans/{plan.id}/bgm")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["plan_id"] == plan.id
|
|
assert data["bgm"] == {}
|
|
|
|
def test_update_bgm_volume(self, client):
|
|
"""更新 BGM 音量"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
repo.create(plan)
|
|
|
|
resp = c.put(
|
|
f"/api/v1/edit-plans/{plan.id}/bgm",
|
|
json={"volume": 0.5, "fade_in": 2.0, "fade_out": 3.0},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["bgm"]["volume"] == 0.5
|
|
assert data["bgm"]["fade_in"] == 2.0
|
|
assert data["bgm"]["fade_out"] == 3.0
|
|
|
|
def test_enable_bgm_with_preset(self, client):
|
|
"""启用 BGM 并指定 preset_id"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
repo.create(plan)
|
|
|
|
resp = c.put(
|
|
f"/api/v1/edit-plans/{plan.id}/bgm",
|
|
json={
|
|
"enabled": True,
|
|
"source": "library",
|
|
"preset_id": "bgm_upbeat_001",
|
|
"volume": 0.3,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["bgm"]["enabled"] is True
|
|
assert data["bgm"]["preset_id"] == "bgm_upbeat_001"
|
|
|
|
def test_enable_bgm_without_source_returns_400(self, client):
|
|
"""启用 BGM 但不指定来源,返回 400"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
repo.create(plan)
|
|
|
|
resp = c.put(
|
|
f"/api/v1/edit-plans/{plan.id}/bgm",
|
|
json={"enabled": True, "volume": 0.3},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "素材来源" in resp.json()["detail"]
|
|
|
|
def test_enable_bgm_with_asset_id(self, client):
|
|
"""启用 BGM 并指定 asset_id"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
repo.create(plan)
|
|
|
|
resp = c.put(
|
|
f"/api/v1/edit-plans/{plan.id}/bgm",
|
|
json={
|
|
"enabled": True,
|
|
"source": "upload",
|
|
"asset_id": "asset-audio-001",
|
|
"loop_enabled": True,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["bgm"]["enabled"] is True
|
|
assert data["bgm"]["asset_id"] == "asset-audio-001"
|
|
assert data["bgm"]["loop_enabled"] is True
|
|
|
|
def test_update_bgm_not_found(self, client):
|
|
"""不存在的计划返回 404"""
|
|
c, _ = client
|
|
resp = c.put(
|
|
"/api/v1/edit-plans/nonexistent/bgm",
|
|
json={"volume": 0.5},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_get_bgm_not_found(self, client):
|
|
"""不存在的计划返回 404"""
|
|
c, _ = client
|
|
resp = c.get("/api/v1/edit-plans/nonexistent/bgm")
|
|
assert resp.status_code == 404
|
|
|
|
def test_partial_update_preserves_existing(self, client):
|
|
"""部分更新保留原有配置"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
plan.config = {"bgm": {"volume": 0.5, "fade_in": 1.0}}
|
|
repo.create(plan)
|
|
|
|
# 只改音量
|
|
resp = c.put(
|
|
f"/api/v1/edit-plans/{plan.id}/bgm",
|
|
json={"volume": 0.8},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["bgm"]["volume"] == 0.8
|
|
assert data["bgm"]["fade_in"] == 1.0 # 保留
|
|
|
|
def test_sidechain_config(self, client):
|
|
"""人声闪避配置更新"""
|
|
c, repo = client
|
|
plan = EditPlan.create("tpl-001", "测试")
|
|
repo.create(plan)
|
|
|
|
resp = c.put(
|
|
f"/api/v1/edit-plans/{plan.id}/bgm",
|
|
json={
|
|
"enabled": True,
|
|
"preset_id": "bgm_relax_001",
|
|
"sidechain_enabled": True,
|
|
"sidechain_ratio": 0.4,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["bgm"]["sidechain_enabled"] is True
|
|
assert data["bgm"]["sidechain_ratio"] == 0.4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BGM 预设库测试
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBGMPresets:
|
|
"""BGM 预设列表 API 测试"""
|
|
|
|
def test_list_all_presets(self, client):
|
|
"""获取所有预设 BGM"""
|
|
c, _ = client
|
|
resp = c.get("/api/v1/edit-plans/bgm/presets")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "items" in data
|
|
assert "total" in data
|
|
assert "styles" in data
|
|
assert data["total"] >= 10 # 至少有 10 首预设
|
|
assert len(data["items"]) == data["total"]
|
|
|
|
def test_filter_by_style(self, client):
|
|
"""按风格筛选"""
|
|
c, _ = client
|
|
resp = c.get("/api/v1/edit-plans/bgm/presets?style=upbeat")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] >= 3
|
|
for item in data["items"]:
|
|
assert item["style"] == "upbeat"
|
|
|
|
def test_search_by_keyword(self, client):
|
|
"""关键词搜索"""
|
|
c, _ = client
|
|
resp = c.get("/api/v1/edit-plans/bgm/presets?keyword=钢琴")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] >= 1
|
|
for item in data["items"]:
|
|
has_piano = (
|
|
"钢琴" in item["name"] or "钢琴" in item["description"] or any("钢琴" in tag for tag in item["tags"])
|
|
)
|
|
assert has_piano
|
|
|
|
def test_pagination(self, client):
|
|
"""分页功能"""
|
|
c, _ = client
|
|
resp = c.get("/api/v1/edit-plans/bgm/presets?skip=0&limit=3")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["items"]) == 3
|
|
assert data["skip"] == 0
|
|
assert data["limit"] == 3
|
|
|
|
def test_preset_structure(self, client):
|
|
"""预设条目字段完整"""
|
|
c, _ = client
|
|
resp = c.get("/api/v1/edit-plans/bgm/presets?limit=1")
|
|
assert resp.status_code == 200
|
|
item = resp.json()["items"][0]
|
|
|
|
assert "id" in item
|
|
assert "name" in item
|
|
assert "style" in item
|
|
assert "style_label" in item
|
|
assert "duration" in item
|
|
assert "artist" in item
|
|
assert "description" in item
|
|
assert "tags" in item
|
|
assert isinstance(item["tags"], list)
|