""" templates_editor.py 模板编辑器 API 端点单元测试 覆盖核心端点(23个测试用例): - 草稿: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" clip.template_clip_config_id = "" clip.created_at = None clip.updated_at = None 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 "merged_clip" in data assert "id" in data["merged_clip"] 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 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"]