419cc3fb6a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m58s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m32s
CI/CD Pipeline / Unit Tests (push) Failing after 6m48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m5s
CI/CD Pipeline / Integration Tests (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 9m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m40s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
528 lines
18 KiB
Python
Executable File
528 lines
18 KiB
Python
Executable File
"""
|
||
templates_editor.py 模板编辑器 API 端点单元测试
|
||
|
||
覆盖核心端点(25个测试用例):
|
||
- 草稿:GET/PUT/发布
|
||
- 片段:list/create/get/update/delete/split/merge
|
||
- BGM:GET/PUT
|
||
- 时间线:GET
|
||
- 生成状态查询
|
||
- 预设:BGM预设
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 测试常量
|
||
# ---------------------------------------------------------------------------
|
||
|
||
TEST_TEMPLATE_ID = "tmpl-test-001"
|
||
TEST_PLAN_ID = "plan-draft-001"
|
||
TEST_USER_ID = "user-001"
|
||
|
||
|
||
def _make_auth_user():
|
||
"""构造一个认证用户 stub(MagicMock 兼容不同属性名)"""
|
||
auth = MagicMock()
|
||
auth.user.id = TEST_USER_ID
|
||
auth.user.email = "test@example.com"
|
||
auth.user.display_name = "测试用户"
|
||
auth.user_id = TEST_USER_ID
|
||
return auth
|
||
|
||
|
||
def _make_mock_clip(clip_id="clip-001", order=0, duration=10.0, clip_type="video"):
|
||
"""构造一个 mock 片段"""
|
||
clip = MagicMock()
|
||
clip.id = clip_id
|
||
clip.plan_id = TEST_PLAN_ID
|
||
clip.clip_type = clip_type
|
||
clip.order = order
|
||
clip.duration = duration
|
||
clip.start_time = 0.0
|
||
clip.text_content = ""
|
||
clip.transition_effect = "none"
|
||
clip.playback_speed = 1.0
|
||
clip.config = {}
|
||
clip.asset_id = "asset-001"
|
||
clip.status = "ready"
|
||
return clip
|
||
|
||
|
||
def _make_mock_plan(status="editing", config=None):
|
||
"""构造一个 mock 剪辑计划"""
|
||
plan = MagicMock()
|
||
plan.id = TEST_PLAN_ID
|
||
plan.status = status
|
||
plan.config = config or {"is_template_draft": True, "asset_ids": []}
|
||
plan.template_id = TEST_TEMPLATE_ID
|
||
plan.project_id = "proj-001"
|
||
plan.name = "测试草稿"
|
||
plan.total_duration = 30.0
|
||
plan.generation_task_id = None
|
||
return plan
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test App Setup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _create_test_app():
|
||
"""创建带 mock 注入的模板编辑器测试应用"""
|
||
from app.api.routes import templates_editor as editor_module
|
||
|
||
mock_plan = _make_mock_plan()
|
||
mock_clip_1 = _make_mock_clip("clip-001", 0, 10.0)
|
||
mock_clip_2 = _make_mock_clip("clip-002", 1, 20.0)
|
||
|
||
mock_template_svc = MagicMock()
|
||
mock_template_svc.publish_template_from_draft.return_value = MagicMock(
|
||
id=TEST_TEMPLATE_ID,
|
||
name="发布后的模板",
|
||
status="published",
|
||
version=2,
|
||
)
|
||
mock_template_svc.list_template_versions.return_value = []
|
||
mock_template_svc.rollback_to_version.return_value = MagicMock(
|
||
id=TEST_TEMPLATE_ID,
|
||
name="回滚后的模板",
|
||
status="active",
|
||
version=3,
|
||
)
|
||
mock_template_svc.list_clip_configs.return_value = []
|
||
|
||
mock_plan_svc = MagicMock()
|
||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||
mock_plan_svc.update_plan.return_value = mock_plan
|
||
mock_plan_svc.update_plan_config.return_value = mock_plan
|
||
mock_plan_svc.list_clips.return_value = [mock_clip_1, mock_clip_2]
|
||
mock_plan_svc.count_clips.return_value = 2
|
||
mock_plan_svc.get_clip.return_value = mock_clip_1
|
||
mock_plan_svc.create_clip.return_value = _make_mock_clip("clip-new", order=2)
|
||
mock_plan_svc.update_clip.return_value = _make_mock_clip("clip-001", duration=15.0)
|
||
mock_plan_svc.delete_clip.return_value = True
|
||
mock_plan_svc.split_clip.return_value = {
|
||
"left_clip": _make_mock_clip("clip-left", 0, 5.0),
|
||
"right_clip": _make_mock_clip("clip-right", 1, 5.0),
|
||
}
|
||
mock_plan_svc.merge_clips.return_value = _make_mock_clip("clip-merged", 0, 20.0)
|
||
mock_plan_svc.can_generate.return_value = (True, None)
|
||
mock_plan_svc.mark_clips_ready.return_value = 2
|
||
mock_plan_svc.transition_status.return_value = mock_plan
|
||
|
||
# 生成任务 mock
|
||
mock_gen_task = MagicMock()
|
||
mock_gen_task.id = "task-001"
|
||
mock_gen_task.status = "pending"
|
||
mock_plan_svc.create_generation_task.return_value = mock_gen_task
|
||
# 生成状态返回结构(需要 plan + clips)
|
||
mock_plan_svc.get_generation_status.return_value = {
|
||
"plan": mock_plan,
|
||
"clips": [mock_clip_1, mock_clip_2],
|
||
"task_id": "task-001",
|
||
"generation_task_id": "task-001",
|
||
"status": "processing",
|
||
}
|
||
mock_plan_svc.list_generation_tasks.return_value = {"items": [], "total": 0}
|
||
|
||
# mock get_editor_services 依赖
|
||
def _mock_get_editor_services():
|
||
return mock_template_svc, mock_plan_svc
|
||
|
||
app = FastAPI()
|
||
app.include_router(
|
||
editor_module.router,
|
||
prefix="/api/v1/templates/{template_id}/editor",
|
||
)
|
||
|
||
# 覆盖依赖
|
||
app.dependency_overrides[editor_module.get_current_user] = _make_auth_user
|
||
app.dependency_overrides[editor_module.get_db_session] = lambda: MagicMock()
|
||
app.dependency_overrides[editor_module.get_draft_plan_id] = lambda: TEST_PLAN_ID
|
||
app.dependency_overrides[editor_module.get_editor_services] = _mock_get_editor_services
|
||
|
||
return app, mock_template_svc, mock_plan_svc
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
app, mock_tpl_svc, mock_plan_svc = _create_test_app()
|
||
yield TestClient(app), mock_tpl_svc, mock_plan_svc
|
||
|
||
|
||
BASE = f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 草稿端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestDraftEndpoints:
|
||
"""草稿查询/更新/发布端点测试"""
|
||
|
||
def test_get_draft_success(self, client):
|
||
c, _, _ = client
|
||
resp = c.get(BASE + "/")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["plan_id"] == TEST_PLAN_ID
|
||
assert data["template_id"] == TEST_TEMPLATE_ID
|
||
assert "name" in data
|
||
assert "config" in data
|
||
assert "clip_count" in data
|
||
|
||
def test_get_draft_returns_is_template_draft(self, client):
|
||
c, _, _ = client
|
||
resp = c.get(BASE + "/")
|
||
data = resp.json()
|
||
assert data["config"]["is_template_draft"] is True
|
||
|
||
def test_update_draft_name(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.put(BASE + "/", json={"name": "新名称"})
|
||
assert resp.status_code == 200
|
||
mock_plan_svc.update_plan.assert_called_once()
|
||
call_kwargs = mock_plan_svc.update_plan.call_args
|
||
assert call_kwargs.kwargs.get("name") == "新名称" or call_kwargs[1].get("name") == "新名称"
|
||
|
||
def test_update_draft_empty_body_ok(self, client):
|
||
c, _, _ = client
|
||
resp = c.put(BASE + "/", json={})
|
||
assert resp.status_code == 200
|
||
|
||
def test_publish_draft_success(self, client):
|
||
c, mock_tpl_svc, _ = client
|
||
resp = c.post(BASE + "/publish")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["status"] == "published"
|
||
assert data["template_id"] == TEST_TEMPLATE_ID
|
||
mock_tpl_svc.publish_template_from_draft.assert_called_once_with(TEST_TEMPLATE_ID, TEST_PLAN_ID)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 片段端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestClipEndpoints:
|
||
"""片段 CRUD 端点测试"""
|
||
|
||
def test_list_clips_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/clips")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "items" in data
|
||
assert "total" in data
|
||
assert data["total"] == 2
|
||
assert len(data["items"]) == 2
|
||
mock_plan_svc.list_clips.assert_called_once()
|
||
|
||
def test_list_clips_pagination_params(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/clips?skip=10&limit=20")
|
||
assert resp.status_code == 200
|
||
mock_plan_svc.list_clips.assert_called_once_with(TEST_PLAN_ID, skip=10, limit=20)
|
||
|
||
def test_create_clip_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.post(
|
||
BASE + "/clips",
|
||
json={"clip_type": "video", "order": 2, "duration": 5.0},
|
||
)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["id"] == "clip-new"
|
||
mock_plan_svc.create_clip.assert_called_once()
|
||
|
||
def test_create_clip_missing_type_422(self, client):
|
||
c, _, _ = client
|
||
resp = c.post(BASE + "/clips", json={"order": 0, "duration": 5.0})
|
||
assert resp.status_code == 422
|
||
|
||
def test_get_clip_detail_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/clips/clip-001")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["id"] == "clip-001"
|
||
mock_plan_svc.get_clip.assert_called_once_with("clip-001")
|
||
|
||
def test_get_clip_not_found_404(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.get_clip.return_value = None
|
||
resp = c.get(BASE + "/clips/nonexistent")
|
||
assert resp.status_code == 404
|
||
|
||
def test_update_clip_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.put(
|
||
BASE + "/clips/clip-001",
|
||
json={"duration": 15.0, "playback_speed": 2.0},
|
||
)
|
||
assert resp.status_code == 200
|
||
mock_plan_svc.update_clip.assert_called_once()
|
||
|
||
def test_delete_clip_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.delete(BASE + "/clips/clip-001")
|
||
assert resp.status_code == 204
|
||
mock_plan_svc.delete_clip.assert_called_once_with("clip-001")
|
||
|
||
def test_delete_clip_not_found_404(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.delete_clip.return_value = False
|
||
resp = c.delete(BASE + "/clips/nonexistent")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 片段分割合并测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestClipSplitMerge:
|
||
"""片段分割与合并端点测试"""
|
||
|
||
def test_split_clip_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.post(BASE + "/clips/clip-001/split", json={"split_time": 5.0})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "left_clip" in data
|
||
assert "right_clip" in data
|
||
mock_plan_svc.split_clip.assert_called_once_with("clip-001", 5.0)
|
||
|
||
def test_split_clip_negative_time_422(self, client):
|
||
c, _, _ = client
|
||
resp = c.post(BASE + "/clips/clip-001/split", json={"split_time": -1.0})
|
||
assert resp.status_code == 422
|
||
|
||
def test_split_clip_not_found_404(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.get_clip.return_value = None
|
||
resp = c.post(BASE + "/clips/nonexistent/split", json={"split_time": 5.0})
|
||
assert resp.status_code == 404
|
||
|
||
def test_merge_clips_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.post(BASE + "/clips/merge", json={"clip_ids": ["clip-001", "clip-002"]})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "id" in data
|
||
mock_plan_svc.merge_clips.assert_called_once_with(["clip-001", "clip-002"])
|
||
|
||
def test_merge_clips_single_422(self, client):
|
||
c, _, _ = client
|
||
resp = c.post(BASE + "/clips/merge", json={"clip_ids": ["clip-001"]})
|
||
assert resp.status_code == 422
|
||
|
||
def test_merge_clips_not_found_404(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.get_clip.return_value = None
|
||
resp = c.post(BASE + "/clips/merge", json={"clip_ids": ["nope", "clip-002"]})
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# BGM 端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBGMRoutes:
|
||
"""BGM 配置端点测试"""
|
||
|
||
def test_get_bgm_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/bgm")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "plan_id" in data
|
||
assert "bgm" in data
|
||
mock_plan_svc.get_plan_or_raise.assert_called()
|
||
|
||
def test_update_bgm_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.put(BASE + "/bgm", json={"enabled": True, "asset_id": "asset-001", "volume": 0.6})
|
||
assert resp.status_code == 200
|
||
mock_plan_svc.update_plan_config.assert_called_once()
|
||
|
||
def test_get_bgm_presets_success(self, client):
|
||
c, _, _ = client
|
||
resp = c.get(BASE + "/bgm/presets")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert isinstance(data, dict)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 时间线端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTimelineRoute:
|
||
"""时间线条端点测试"""
|
||
|
||
def test_get_timeline_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/timeline")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "plan_id" in data
|
||
assert "scenes" in data
|
||
mock_plan_svc.list_clips.assert_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 生成端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestGenerationRoutes:
|
||
"""生成端点测试"""
|
||
|
||
def test_generation_status_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/generation-status")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "generation_task_id" in data
|
||
assert "clips" in data
|
||
|
||
def test_generations_list_success(self, client):
|
||
c, _, mock_plan_svc = client
|
||
resp = c.get(BASE + "/generations")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert "items" in data or "tasks" in data or isinstance(data, dict)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 字幕端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSubtitleRoutes:
|
||
"""字幕端点测试"""
|
||
|
||
def test_get_subtitles_empty(self, client):
|
||
c, _, _ = client
|
||
resp = c.get(BASE + "/clips/clip-001/subtitles")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert isinstance(data, list)
|
||
|
||
def test_get_subtitles_not_found_404(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.get_clip.return_value = None
|
||
resp = c.get(BASE + "/clips/nonexistent/subtitles")
|
||
assert resp.status_code == 404
|
||
|
||
def test_create_subtitle(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.update_clip.return_value = _make_mock_clip()
|
||
resp = c.post(
|
||
BASE + "/clips/clip-001/subtitles",
|
||
json={"start_time": 0, "end_time": 2, "text": "hello"},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["text"] == "hello"
|
||
mock_plan_svc.update_clip.assert_called_once()
|
||
|
||
def test_delete_subtitle_not_found_404(self, client):
|
||
c, _, mock_plan_svc = client
|
||
mock_plan_svc.get_clip.return_value = None
|
||
resp = c.delete(BASE + "/clips/nonexistent/subtitles/sub-001")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 片段调整端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 版本管理端点测试
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestVersioningEndpoints:
|
||
"""模板版本历史 + 回滚端点测试"""
|
||
|
||
def test_publish_returns_version(self, client):
|
||
"""发布后返回新版本号"""
|
||
c, mock_tpl_svc, _ = client
|
||
resp = c.post(BASE + "/publish")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["version"] == 2
|
||
assert data["status"] == "published"
|
||
|
||
def test_list_versions_empty(self, client):
|
||
"""查询版本历史,空列表也正常返回"""
|
||
c, mock_tpl_svc, _ = client
|
||
resp = c.get(BASE + "/versions")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["total"] == 0
|
||
assert data["versions"] == []
|
||
mock_tpl_svc.list_template_versions.assert_called_once_with(TEST_TEMPLATE_ID, limit=50)
|
||
|
||
def test_list_versions_with_limit(self, client):
|
||
"""版本历史支持 limit 参数"""
|
||
c, mock_tpl_svc, _ = client
|
||
resp = c.get(BASE + "/versions?limit=10")
|
||
assert resp.status_code == 200
|
||
mock_tpl_svc.list_template_versions.assert_called_once_with(TEST_TEMPLATE_ID, limit=10)
|
||
|
||
def test_list_versions_limit_too_large_422(self, client):
|
||
"""limit 超过上限返回 422"""
|
||
c, _, _ = client
|
||
resp = c.get(BASE + "/versions?limit=500")
|
||
assert resp.status_code == 422
|
||
|
||
def test_rollback_success(self, client):
|
||
"""回滚到指定版本成功"""
|
||
c, mock_tpl_svc, _ = client
|
||
resp = c.post(BASE + "/rollback", json={"version": 1})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["status"] == "rolled_back"
|
||
assert data["rollback_to_version"] == 1
|
||
assert data["new_version"] == 3
|
||
assert data["template_id"] == TEST_TEMPLATE_ID
|
||
mock_tpl_svc.rollback_to_version.assert_called_once_with(TEST_TEMPLATE_ID, 1)
|
||
|
||
def test_rollback_missing_version_422(self, client):
|
||
"""回滚请求缺 version 返回 422"""
|
||
c, _, _ = client
|
||
resp = c.post(BASE + "/rollback", json={})
|
||
assert resp.status_code == 422
|
||
|
||
def test_rollback_value_error_400(self, client):
|
||
"""回滚目标版本不存在返回 400"""
|
||
c, mock_tpl_svc, _ = client
|
||
mock_tpl_svc.rollback_to_version.side_effect = ValueError("版本不存在")
|
||
resp = c.post(BASE + "/rollback", json={"version": 99})
|
||
assert resp.status_code == 400
|
||
assert "不存在" in resp.json()["detail"]
|