"""片段管理路由 clips.py 增量覆盖率测试. 覆盖 PR fix/clips-api-response-structure 新增代码: - _clip_to_response: 枚举转换、日期格式化、asset_url 参数 - _build_asset_url_map: 批量素材 URL 解析(空列表/异常/正常路径) - 路由层 asset_repo 注入与 URL 拼接逻辑 """ from __future__ import annotations import os import sys from enum import Enum 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") sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) import pytest 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 _auth_user(): u = MagicMock() u.user.id = TEST_USER_ID u.user_id = TEST_USER_ID return u def _clip(**overrides): """构造 mock clip,支持 Enum 类型字段""" c = MagicMock() c.id = overrides.get("id", "clip-001") c.plan_id = overrides.get("plan_id", TEST_PLAN_ID) c.clip_type = overrides.get("clip_type", "video") c.order = overrides.get("order", 0) c.duration = overrides.get("duration", 10.0) c.start_time = overrides.get("start_time", 0.0) c.text_content = overrides.get("text_content", "") c.transition_effect = overrides.get("transition_effect", "cut") c.transition_duration = overrides.get("transition_duration", 0.0) c.playback_speed = overrides.get("playback_speed", 1.0) c.asset_id = overrides.get("asset_id", "") c.status = overrides.get("status", "ready") c.template_clip_config_id = overrides.get("template_clip_config_id", "") c.config = overrides.get("config", {}) c.created_at = overrides.get("created_at", None) c.updated_at = overrides.get("updated_at", None) return c def _services(plan_svc_overrides=None): tpl = MagicMock() plan = MagicMock() if plan_svc_overrides: for k, v in plan_svc_overrides.items(): setattr(plan, k, v) return tpl, plan # --------------------------------------------------------------------------- # 单元测试: _clip_to_response # --------------------------------------------------------------------------- class TestClipToResponse: """_clip_to_response 纯函数测试 — 覆盖行 53-80""" def test_basic_fields(self): from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(id="c1", order=3, duration=5.5, text_content="hello") resp = _clip_to_response(c) assert resp.id == "c1" assert resp.order == 3 assert resp.duration == 5.5 assert resp.text_content == "hello" assert resp.asset_url is None def test_enum_clip_type(self): """Enum 值应被 .value 解包""" from app.api.routes.templates_editor.clips import _clip_to_response class ClipType(str, Enum): VIDEO = "video" AUDIO = "audio" c = _clip(clip_type=ClipType.VIDEO) resp = _clip_to_response(c) assert resp.clip_type == "video" def test_plain_string_clip_type(self): """非 Enum 字符串直接用 str()""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(clip_type="main") resp = _clip_to_response(c) assert resp.clip_type == "main" def test_enum_transition_effect(self): from app.api.routes.templates_editor.clips import _clip_to_response class Transition(str, Enum): FADE = "fade" c = _clip(transition_effect=Transition.FADE) resp = _clip_to_response(c) assert resp.transition_effect == "fade" def test_default_transition_when_none(self): """transition_effect 缺失时默认 cut""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip() del c.transition_effect # 触发 getattr default resp = _clip_to_response(c) assert resp.transition_effect == "cut" def test_asset_url_passed(self): """asset_url 参数应透传到响应""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(asset_id="a1") resp = _clip_to_response(c, asset_url="https://signed-url.example.com/video.mp4") assert resp.asset_url == "https://signed-url.example.com/video.mp4" def test_asset_url_none_by_default(self): from app.api.routes.templates_editor.clips import _clip_to_response c = _clip() resp = _clip_to_response(c) assert resp.asset_url is None def test_datetime_isoformat(self): """datetime 对象应被 isoformat()""" from datetime import datetime from app.api.routes.templates_editor.clips import _clip_to_response dt = datetime(2026, 8, 17, 12, 0, 0) c = _clip(created_at=dt, updated_at=dt) resp = _clip_to_response(c) assert "2026-08-17" in resp.created_at assert "2026-08-17" in resp.updated_at def test_none_datetime_empty_string(self): """None 日期应格式化为空字符串""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(created_at=None, updated_at=None) resp = _clip_to_response(c) assert resp.created_at == "" assert resp.updated_at == "" def test_string_datetime_passthrough(self): """已经是字符串的日期直接 str()""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(created_at="2026-08-17T00:00:00") resp = _clip_to_response(c) assert resp.created_at == "2026-08-17T00:00:00" def test_none_defaults_for_optional_fields(self): """None/缺失字段的默认值""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(asset_id=None, status=None, template_clip_config_id=None) resp = _clip_to_response(c) assert resp.asset_id == "" assert resp.status == "pending" assert resp.template_clip_config_id == "" def test_zero_duration_fallback(self): """duration=0 → playback_speed 默认 1.0""" from app.api.routes.templates_editor.clips import _clip_to_response c = _clip(playback_speed=None) resp = _clip_to_response(c) assert resp.playback_speed == 1.0 # --------------------------------------------------------------------------- # 单元测试: _build_asset_url_map # --------------------------------------------------------------------------- class TestBuildAssetUrlMap: """_build_asset_url_map 测试 — 覆盖行 93-118""" def test_empty_list(self): """空 asset_ids 直接返回空 dict""" from app.api.routes.templates_editor.clips import _build_asset_url_map repo = MagicMock() result = _build_asset_url_map([], repo) assert result == {} @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_storage_service_failure(self, mock_get_storage): """存储服务获取失败时返回全 None""" from app.api.routes.templates_editor.clips import _build_asset_url_map mock_get_storage.side_effect = RuntimeError("storage unavailable") repo = MagicMock() result = _build_asset_url_map(["a1", "a2"], repo) assert result == {"a1": None, "a2": None} @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_asset_not_found(self, mock_get_storage): """asset_id 找不到对应素材 → None""" from app.api.routes.templates_editor.clips import _build_asset_url_map storage = MagicMock() mock_get_storage.return_value = storage repo = MagicMock() repo.find_by_ids.return_value = [] result = _build_asset_url_map(["missing-id"], repo) assert result == {"missing-id": None} @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_no_storage_key(self, mock_get_storage): """素材没有 storage_key → None""" from app.api.routes.templates_editor.clips import _build_asset_url_map storage = MagicMock() mock_get_storage.return_value = storage repo = MagicMock() asset = MagicMock() asset.id = "a1" asset.storage_key = "" repo.find_by_ids.return_value = [asset] result = _build_asset_url_map(["a1"], repo) assert result == {"a1": None} @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_successful_url_generation(self, mock_get_storage): """正常路径:返回签名 URL""" from app.api.routes.templates_editor.clips import _build_asset_url_map storage = MagicMock() storage.get_download_url.return_value = "https://cdn.example.com/signed.mp4" mock_get_storage.return_value = storage repo = MagicMock() asset = MagicMock() asset.id = "a1" asset.storage_key = "videos/test.mp4" repo.find_by_ids.return_value = [asset] result = _build_asset_url_map(["a1"], repo) assert result == {"a1": "https://cdn.example.com/signed.mp4"} storage.get_download_url.assert_called_once_with("videos/test.mp4", expires_seconds=3600) @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_exception_during_url_generation(self, mock_get_storage): """单个 asset 生成 URL 异常 → None,不影响其他""" from app.api.routes.templates_editor.clips import _build_asset_url_map storage = MagicMock() storage.get_download_url.side_effect = [Exception("boom"), "https://ok.com/v2"] mock_get_storage.return_value = storage repo = MagicMock() asset1 = MagicMock() asset1.id = "a1" asset1.storage_key = "v1.mp4" asset2 = MagicMock() asset2.id = "a2" asset2.storage_key = "v2.mp4" repo.find_by_ids.return_value = [asset1, asset2] result = _build_asset_url_map(["a1", "a2"], repo) assert result["a1"] is None assert result["a2"] == "https://ok.com/v2" @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_skip_empty_asset_id(self, mock_get_storage): """空字符串 asset_id 被跳过""" from app.api.routes.templates_editor.clips import _build_asset_url_map storage = MagicMock() mock_get_storage.return_value = storage repo = MagicMock() result = _build_asset_url_map(["", "a1"], repo) # "" not in result because it's skipped by `if not aid: continue` assert "" not in result @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_multiple_assets_mixed(self, mock_get_storage): """混合场景:正常+异常+缺失""" from app.api.routes.templates_editor.clips import _build_asset_url_map storage = MagicMock() storage.get_download_url.return_value = "https://cdn.com/ok.mp4" mock_get_storage.return_value = storage repo = MagicMock() good_asset = MagicMock() good_asset.id = "a1" good_asset.storage_key = "good.mp4" # a1=good, a2=not found, a3=good good_asset2 = MagicMock() good_asset2.id = "a3" good_asset2.storage_key = "good.mp4" repo.find_by_ids.return_value = [good_asset, good_asset2] result = _build_asset_url_map(["a1", "a2", "a3"], repo) assert result["a1"] == "https://cdn.com/ok.mp4" assert result["a2"] is None assert result["a3"] == "https://cdn.com/ok.mp4" # --------------------------------------------------------------------------- # 集成测试: 路由层 asset_repo 注入 # --------------------------------------------------------------------------- class TestClipRoutesAssetIntegration: """路由层测试 — 覆盖 asset_url 在 list/detail/split/merge 中的拼接逻辑""" def _create_app(self, plan_svc_config=None): from app.api.routes import templates_editor as editor_module from app.dependencies import get_asset_repository mock_clip_1 = _clip(id="c1", asset_id="asset-001") mock_clip_2 = _clip(id="c2", asset_id="") mock_tpl_svc = MagicMock() mock_plan_svc = MagicMock() 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 = _clip(id="c-new", asset_id="") mock_plan_svc.update_clip.return_value = _clip(id="c1", duration=15.0) mock_plan_svc.delete_clip.return_value = True mock_plan_svc.split_clip.return_value = { "left_clip": _clip(id="c-left", asset_id="asset-L"), "right_clip": _clip(id="c-right", asset_id="asset-R"), } mock_plan_svc.merge_clips.return_value = _clip(id="c-merged", asset_id="asset-M") if plan_svc_config: for k, v in plan_svc_config.items(): setattr(mock_plan_svc, k, v) def _deps(): return mock_tpl_svc, mock_plan_svc mock_asset_repo = MagicMock() app = FastAPI() app.include_router( editor_module.router, prefix="/api/v1/templates/{template_id}/editor", ) app.dependency_overrides[editor_module.get_current_user] = _auth_user app.dependency_overrides[editor_module.get_draft_plan_id] = lambda: TEST_PLAN_ID app.dependency_overrides[editor_module.get_editor_services] = _deps app.dependency_overrides[get_asset_repository] = lambda: mock_asset_repo return TestClient(app), mock_plan_svc, mock_asset_repo @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_list_clips_includes_asset_urls(self, mock_get_storage): """GET /clips 应为有 asset_id 的片段返回签名 URL""" storage = MagicMock() storage.get_download_url.return_value = "https://cdn.com/c1.mp4" mock_get_storage.return_value = storage client, _, asset_repo = self._create_app() asset = MagicMock() asset.id = "asset-001" asset.storage_key = "videos/c1.mp4" asset_repo.find_by_ids.return_value = [asset] resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips") assert resp.status_code == 200 data = resp.json() items = data["items"] assert len(items) == 2 # c1 has asset_id → should have url assert items[0]["asset_url"] == "https://cdn.com/c1.mp4" # c2 has empty asset_id → None assert items[1]["asset_url"] is None @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_get_clip_detail_with_asset_url(self, mock_get_storage): """GET /clips/{clip_id} 应返回素材签名 URL""" storage = MagicMock() storage.get_download_url.return_value = "https://cdn.com/detail.mp4" mock_get_storage.return_value = storage client, _, asset_repo = self._create_app() asset = MagicMock() asset.id = "asset-001" asset.storage_key = "videos/detail.mp4" asset_repo.find_by_ids.return_value = [asset] resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001") assert resp.status_code == 200 assert resp.json()["asset_url"] == "https://cdn.com/detail.mp4" def test_get_clip_detail_no_asset(self): """片段没有 asset_id 时不应调用 URL 解析""" client, plan_svc, asset_repo = self._create_app() # 返回没有 asset_id 的片段 plan_svc.get_clip.return_value = _clip(id="c-no-asset", asset_id="") resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/c-no-asset") assert resp.status_code == 200 assert resp.json()["asset_url"] is None @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_split_clip_returns_asset_urls(self, mock_get_storage): """POST /clips/{clip_id}/split 返回的左右片段应带签名 URL""" storage = MagicMock() storage.get_download_url.side_effect = ["https://cdn.com/L.mp4", "https://cdn.com/R.mp4"] mock_get_storage.return_value = storage client, _, asset_repo = self._create_app() asset_l = MagicMock() asset_l.storage_key = "videos/L.mp4" asset_r = MagicMock() asset_r.storage_key = "videos/R.mp4" asset_l.id = "asset-L" asset_r.id = "asset-R" asset_repo.find_by_ids.return_value = [asset_l, asset_r] resp = client.post( f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001/split", json={"split_time": 5.0}, ) assert resp.status_code == 200 data = resp.json() assert data["left_clip"]["asset_url"] == "https://cdn.com/L.mp4" assert data["right_clip"]["asset_url"] == "https://cdn.com/R.mp4" @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_merge_clips_returns_asset_url(self, mock_get_storage): """POST /clips/merge 返回的合并片段应带签名 URL""" storage = MagicMock() storage.get_download_url.return_value = "https://cdn.com/M.mp4" mock_get_storage.return_value = storage client, _, asset_repo = self._create_app() asset = MagicMock() asset.id = "asset-M" asset.storage_key = "videos/M.mp4" asset_repo.find_by_ids.return_value = [asset] resp = client.post( f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge", json={"clip_ids": ["c1", "c2"]}, ) assert resp.status_code == 200 data = resp.json() assert data["merged_clip"]["asset_url"] == "https://cdn.com/M.mp4" assert data["deleted_clip_ids"] == ["c1", "c2"] def test_merge_clips_not_found(self): """merge 时某片段不存在应返回 404""" client, plan_svc, _ = self._create_app() plan_svc.get_clip.return_value = None resp = client.post( f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge", json={"clip_ids": ["nonexistent-1", "nonexistent-2"]}, ) assert resp.status_code == 404 def test_delete_clip_success(self): """DELETE /clips/{clip_id} 成功返回 204""" client, _, _ = self._create_app() resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001") assert resp.status_code == 204 def test_delete_clip_not_found(self): """DELETE 片段不存在返回 404""" client, plan_svc, _ = self._create_app() plan_svc.delete_clip.return_value = False resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/bad-id") assert resp.status_code == 404