Files
xiaoxia-saas/tests/unit/test_edit_plan_cover_api.py
T
xiaoxia a1f9557dc5
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
feat: 封面管理 - 配置读写 + 片段抽帧 + 智能选帧 (#396)
feat: 封面管理 - 配置读写 + 片段抽帧 + 智能选帧 (#396)
2026-07-17 10:00:18 +08:00

560 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
封面管理 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_urltype 保持不变"""
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