""" 封面管理 API 单元测试 覆盖: - GET /{plan_id}/cover - 获取封面配置 - PUT /{plan_id}/cover - 更新封面配置 - POST /{plan_id}/cover/extract - 从片段抽帧 - POST /{plan_id}/cover/smart - 智能选帧 """ from __future__ import annotations import os import sys from datetime import datetime 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 from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus # --------------------------------------------------------------------------- # Stub Repository # --------------------------------------------------------------------------- class StubEditPlanRepository: def __init__(self, plans: dict[str, EditPlan] | None = None): self._plans = plans or {} self._counter = 100 def _next_id(self) -> str: self._counter += 1 return f"plan-{self._counter:03d}" def list_all(self, *, status=None, skip=0, limit=50): 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, *, status=None, skip=0, limit=50): 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: if not plan.id: plan.id = self._next_id() self._plans[plan.id] = plan return plan def update(self, plan: EditPlan) -> EditPlan: self._plans[plan.id] = plan return plan def delete(self, plan_id: str) -> bool: if plan_id in self._plans: del self._plans[plan_id] return True return False def count(self, *, status=None, template_id=None): items = list(self._plans.values()) if status is not None: items = [p for p in items if p.status == status] if template_id is not None: items = [p for p in items if p.template_id == template_id] return len(items) class StubEditPlanClipRepository: def __init__(self, clips: dict[str, EditPlanClip] | None = None): self._clips = clips or {} self._counter = 200 def _next_id(self) -> str: self._counter += 1 return f"clip-{self._counter:03d}" def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100): items = [c for c in self._clips.values() if c.plan_id == plan_id] if status is not None: items = [c for c in items if c.status == status] items.sort(key=lambda c: c.order) return items[skip : skip + limit] def count(self, plan_id, *, status=None): items = [c for c in self._clips.values() if c.plan_id == plan_id] if status is not None: items = [c for c in items if c.status == status] return len(items) def get(self, clip_id: str) -> Optional[EditPlanClip]: return self._clips.get(clip_id) def create(self, clip: EditPlanClip) -> EditPlanClip: if not clip.id: clip.id = self._next_id() self._clips[clip.id] = clip return clip def update(self, clip: EditPlanClip) -> EditPlanClip: self._clips[clip.id] = clip return clip def delete(self, clip_id: str) -> bool: if clip_id in self._clips: del self._clips[clip_id] return True return False def delete_by_plan(self, plan_id: str) -> int: to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id] for cid in to_delete: del self._clips[cid] return len(to_delete) class StubAssetRepository: def __init__(self, assets: dict | None = None): self._assets = assets or {} def get(self, asset_id: str): return self._assets.get(asset_id) class StubStorageService: def __init__(self): self.uploaded = {} self.downloaded = {} def upload_file(self, file_or_path, storage_key, content_type="application/octet-stream"): self.uploaded[storage_key] = file_or_path return f"https://oss.example.com/{storage_key}" def get_url(self, storage_key: str) -> str: return f"https://oss.example.com/{storage_key}" def download_file(self, storage_key: str, local_path: str): self.downloaded[storage_key] = local_path # 创建一个假文件(空文件也可以,因为抽帧会被 mock 掉) Path(local_path).parent.mkdir(parents=True, exist_ok=True) with open(local_path, "wb") as f: f.write(b"fake video data for testing") # --------------------------------------------------------------------------- # Test Fixtures # --------------------------------------------------------------------------- def _make_sample_plan(plan_id="plan-001", config=None): if config is None: config = normalize_plan_config({}) return EditPlan( id=plan_id, template_id="tpl-001", name="测试计划", status=EditPlanStatus.EDITING, total_duration=30.0, config=config, project_id="", created_by_user_id="user-001", created_at=datetime(2026, 7, 16, 10, 0, 0), updated_at=datetime(2026, 7, 16, 10, 0, 0), ) def _make_sample_clip(clip_id="clip-001", plan_id="plan-001", asset_id="asset-001", clip_type="video"): return EditPlanClip( id=clip_id, plan_id=plan_id, clip_type=clip_type, order=0, asset_id=asset_id, text_content="", start_time=0.0, duration=10.0, transition_effect="none", transition_duration=0.0, playback_speed=1.0, status=EditPlanClipStatus.READY, config={}, created_at=datetime(2026, 7, 16, 10, 0, 0), updated_at=datetime(2026, 7, 16, 10, 0, 0), ) def _create_test_app(): import app.api.routes.edit_plans_cover as cover_module import app.services.edit_plan_service as service_module from app.api.routes.edit_plans import router # 创建 stub plan = _make_sample_plan() clip = _make_sample_clip() stub_plan_repo = StubEditPlanRepository({plan.id: plan}) stub_clip_repo = StubEditPlanClipRepository({clip.id: clip}) # 替换服务模块中的 Repository 类 original_plan_repo = service_module.SQLAlchemyEditPlanRepository original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock() app = FastAPI() app.include_router(router, prefix="/api/v1/edit-plans") # Mock 认证 def _mock_auth(): mock = MagicMock() mock.user.id = "user-001" return mock # Mock 项目访问检查 import app.api.routes._helpers as helpers_module original_check = helpers_module.check_project_access helpers_module.check_project_access = lambda *a, **kw: None # 覆盖依赖 app.dependency_overrides[cover_module.get_current_user] = _mock_auth app.dependency_overrides[cover_module.get_db_session] = lambda: MagicMock() app.dependency_overrides[cover_module.get_project_repository] = lambda: MagicMock() # Mock storage 和 asset repo stub_storage = StubStorageService() stub_asset_repo = StubAssetRepository( { "asset-001": MagicMock( storage_key="videos/test.mp4", mime_type="video/mp4", ), "asset-img": MagicMock( storage_key="images/test.jpg", mime_type="image/jpeg", ), } ) app.dependency_overrides[cover_module.get_storage_service] = lambda: stub_storage app.dependency_overrides[cover_module.get_asset_repository] = lambda: stub_asset_repo # 也需要覆盖 edit_plans 主模块的 auth(用于其他路由) from app.api.routes import edit_plans as main_module app.dependency_overrides[main_module.get_current_user] = _mock_auth app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock() app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock() def cleanup(): service_module.SQLAlchemyEditPlanRepository = original_plan_repo service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo helpers_module.check_project_access = original_check return app, stub_plan_repo, stub_clip_repo, stub_storage, cleanup @pytest.fixture def cover_client(): app, plan_repo, clip_repo, storage, cleanup = _create_test_app() yield TestClient(app), plan_repo, clip_repo, storage cleanup() # --------------------------------------------------------------------------- # GET /{plan_id}/cover 测试 # --------------------------------------------------------------------------- class TestGetCover: def test_get_default_cover(self, cover_client): c, _, _, _ = cover_client resp = c.get("/api/v1/edit-plans/plan-001/cover") assert resp.status_code == 200 data = resp.json() assert data["type"] == "ai_frame" assert data["image_url"] == "" assert data["frame_time"] is None def test_get_cover_not_found(self, cover_client): c, _, _, _ = cover_client resp = c.get("/api/v1/edit-plans/plan-nonexist/cover") assert resp.status_code == 404 def test_get_cover_with_custom_config(self, cover_client): c, plan_repo, _, _ = cover_client # 更新 plan 的 cover 配置 plan = plan_repo.get("plan-001") new_config = dict(plan.config) new_config["cover"] = {"type": "manual", "image_url": "https://example.com/cover.jpg", "frame_time": 5.5} plan.config = new_config plan_repo.update(plan) resp = c.get("/api/v1/edit-plans/plan-001/cover") assert resp.status_code == 200 data = resp.json() assert data["type"] == "manual" assert data["image_url"] == "https://example.com/cover.jpg" assert data["frame_time"] == 5.5 # --------------------------------------------------------------------------- # PUT /{plan_id}/cover 测试 # --------------------------------------------------------------------------- class TestUpdateCover: def test_update_cover_type_and_url(self, cover_client): c, plan_repo, _, _ = cover_client resp = c.put( "/api/v1/edit-plans/plan-001/cover", json={"type": "upload", "image_url": "https://example.com/uploaded.jpg"}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "upload" assert data["image_url"] == "https://example.com/uploaded.jpg" # 验证存储 plan = plan_repo.get("plan-001") assert plan.config["cover"]["type"] == "upload" assert plan.config["cover"]["image_url"] == "https://example.com/uploaded.jpg" def test_update_cover_frame_time(self, cover_client): c, plan_repo, _, _ = cover_client resp = c.put( "/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 3.14}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "manual" assert data["frame_time"] == 3.14 plan = plan_repo.get("plan-001") assert plan.config["cover"]["frame_time"] == 3.14 def test_update_cover_invalid_type(self, cover_client): c, _, _, _ = cover_client resp = c.put( "/api/v1/edit-plans/plan-001/cover", json={"type": "invalid_type"}, ) assert resp.status_code == 400 def test_update_cover_not_found(self, cover_client): c, _, _, _ = cover_client resp = c.put( "/api/v1/edit-plans/plan-nonexist/cover", json={"type": "upload", "image_url": "test.jpg"}, ) assert resp.status_code == 404 def test_update_cover_partial(self, cover_client): """只更新 image_url,type 保持不变""" c, plan_repo, _, _ = cover_client # 先设置一个类型 c.put("/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 2.0}) # 只更新 image_url resp = c.put( "/api/v1/edit-plans/plan-001/cover", json={"image_url": "https://example.com/new.jpg"}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "manual" # 保持不变 assert data["image_url"] == "https://example.com/new.jpg" assert data["frame_time"] == 2.0 # 保持不变 # --------------------------------------------------------------------------- # POST /{plan_id}/cover/extract 测试 # --------------------------------------------------------------------------- class TestExtractCover: def test_extract_success(self, cover_client): c, plan_repo, _, _ = cover_client with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract: # mock ffmpeg 抽帧,直接创建输出文件 def fake_extract(video_path, output_path, **kwargs): output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"fake jpeg data") mock_extract.side_effect = fake_extract resp = c.post( "/api/v1/edit-plans/plan-001/cover/extract", json={"clip_id": "clip-001", "frame_time": 2.5}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "manual" assert data["frame_time"] == 2.5 assert data["image_url"].startswith("https://oss.example.com/covers/") # 验证 plan.config 已更新 plan = plan_repo.get("plan-001") assert plan.config["cover"]["type"] == "manual" assert plan.config["cover"]["frame_time"] == 2.5 def test_extract_clip_not_found(self, cover_client): c, _, _, _ = cover_client resp = c.post( "/api/v1/edit-plans/plan-001/cover/extract", json={"clip_id": "clip-nonexist", "frame_time": 1.0}, ) assert resp.status_code == 404 def test_extract_plan_not_found(self, cover_client): c, _, _, _ = cover_client resp = c.post( "/api/v1/edit-plans/plan-nonexist/cover/extract", json={"clip_id": "clip-001", "frame_time": 1.0}, ) assert resp.status_code == 404 def test_extract_clip_no_asset(self, cover_client): c, _, clip_repo, _ = cover_client # 创建一个没有 asset 的片段 empty_clip = _make_sample_clip(clip_id="clip-empty", asset_id="") clip_repo.create(empty_clip) resp = c.post( "/api/v1/edit-plans/plan-001/cover/extract", json={"clip_id": "clip-empty", "frame_time": 1.0}, ) assert resp.status_code == 400 assert "没有关联素材" in resp.json()["detail"] def test_extract_clip_not_in_plan(self, cover_client): c, _, clip_repo, _ = cover_client # 创建属于另一个 plan 的片段 other_clip = _make_sample_clip(clip_id="clip-other", plan_id="plan-other") clip_repo.create(other_clip) resp = c.post( "/api/v1/edit-plans/plan-001/cover/extract", json={"clip_id": "clip-other", "frame_time": 1.0}, ) assert resp.status_code == 400 assert "不属于该剪辑计划" in resp.json()["detail"] def test_extract_negative_frame_time(self, cover_client): c, _, _, _ = cover_client resp = c.post( "/api/v1/edit-plans/plan-001/cover/extract", json={"clip_id": "clip-001", "frame_time": -1.0}, ) assert resp.status_code == 422 # pydantic 校验失败 # --------------------------------------------------------------------------- # POST /{plan_id}/cover/smart 测试 # --------------------------------------------------------------------------- class TestSmartCover: def test_smart_cover_with_clip_id(self, cover_client): c, plan_repo, _, _ = cover_client with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract: def fake_extract(video_path, output_path, **kwargs): output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"fake jpeg data") mock_extract.side_effect = fake_extract resp = c.post( "/api/v1/edit-plans/plan-001/cover/smart", json={"clip_id": "clip-001"}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "ai_frame" assert data["image_url"].startswith("https://oss.example.com/covers/") plan = plan_repo.get("plan-001") assert plan.config["cover"]["type"] == "ai_frame" def test_smart_cover_auto_pick_first_video(self, cover_client): c, plan_repo, clip_repo, _ = cover_client # 添加多个片段,第一个视频应该被选中 clip2 = _make_sample_clip(clip_id="clip-002", clip_type="audio", asset_id="asset-audio") clip2.order = 1 clip_repo.create(clip2) with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract: def fake_extract(video_path, output_path, **kwargs): output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"fake jpeg data") mock_extract.side_effect = fake_extract resp = c.post( "/api/v1/edit-plans/plan-001/cover/smart", json={}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "ai_frame" def test_smart_cover_clip_not_found(self, cover_client): c, _, _, _ = cover_client resp = c.post( "/api/v1/edit-plans/plan-001/cover/smart", json={"clip_id": "clip-nonexist"}, ) assert resp.status_code == 404 def test_smart_cover_no_video_clips(self, cover_client): c, _, clip_repo, _ = cover_client # 删除原有片段,添加纯音频片段 clip_repo.delete("clip-001") audio_clip = _make_sample_clip(clip_id="clip-audio", clip_type="audio", asset_id="asset-001") clip_repo.create(audio_clip) resp = c.post( "/api/v1/edit-plans/plan-001/cover/smart", json={}, ) assert resp.status_code == 400 assert "没有找到可用的视频片段" in resp.json()["detail"] def test_smart_cover_plan_not_found(self, cover_client): c, _, _, _ = cover_client resp = c.post( "/api/v1/edit-plans/plan-nonexist/cover/smart", json={}, ) assert resp.status_code == 404