Files
xiaoxia-saas/tests/unit/test_edit_template_service.py
T
xiaoxia 4148ae29a4
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m35s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m36s
CI/CD Pipeline / Unit Tests (push) Successful in 3m27s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 9m2s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m31s
feat: 剪辑计划保存为模板 - API + Service + 11个单测 (#392)
2026-07-16 13:52:58 +08:00

754 lines
27 KiB
Python
Executable File
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.
"""
EditTemplateService 单元测试
覆盖(30+ 测试用例):
- 模板 CRUD:创建、查询、更新、软删除
- 名称去重校验
- 片段配置 CRUD
- 片段配置重排序
- 复合查询 get_template_with_configs
- 异常处理:不存在、参数校验
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from unittest.mock import MagicMock
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 packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# ---------------------------------------------------------------------------
# Stub Repositories
# ---------------------------------------------------------------------------
class StubEditTemplateRepository:
"""内存中的 EditTemplate 仓储 stub"""
def __init__(self) -> None:
self._templates: dict[str, EditTemplate] = {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"tpl-{self._counter:03d}"
def list_all(
self,
*,
template_type: Optional[str] = None,
status: Optional[EditTemplateStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditTemplate]:
items = list(self._templates.values())
if template_type:
items = [t for t in items if t.template_type == template_type]
if status:
items = [t for t in items if t.status == status]
return items[skip : skip + limit]
def list_active(
self,
*,
template_type: Optional[str] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditTemplate]:
items = [t for t in self._templates.values() if t.status == EditTemplateStatus.ACTIVE]
if template_type:
items = [t for t in items if t.template_type == template_type]
return items[skip : skip + limit]
def get(self, template_id: str) -> Optional[EditTemplate]:
return self._templates.get(template_id)
def create(self, template: EditTemplate) -> EditTemplate:
if not template.id:
template = EditTemplate(
id=self._next_id(),
name=template.name,
description=template.description,
template_type=template.template_type,
config=template.config,
preview_url=template.preview_url,
sort_weight=template.sort_weight,
status=template.status,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
self._templates[template.id] = template
return template
def update(self, template: EditTemplate) -> EditTemplate:
self._templates[template.id] = template
return template
def delete(self, template_id: str) -> bool:
return self._templates.pop(template_id, None) is not None
def count(
self,
*,
template_type: Optional[str] = None,
status: Optional[EditTemplateStatus] = None,
) -> int:
items = list(self._templates.values())
if template_type:
items = [t for t in items if t.template_type == template_type]
if status:
items = [t for t in items if t.status == status]
return len(items)
class StubTemplateClipConfigRepository:
"""内存中的 TemplateClipConfig 仓储 stub"""
def __init__(self) -> None:
self._configs: dict[str, TemplateClipConfig] = {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"cfg-{self._counter:03d}"
def list_by_template(
self,
template_id: str,
*,
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
items = [c for c in self._configs.values() if c.template_id == template_id]
if clip_type:
items = [c for c in items if c.clip_type == clip_type]
items.sort(key=lambda c: c.order)
return items[skip : skip + limit]
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
return self._configs.get(config_id)
def create(self, config: TemplateClipConfig) -> TemplateClipConfig:
if not config.id:
config = TemplateClipConfig(
id=self._next_id(),
template_id=config.template_id,
clip_type=config.clip_type,
order=config.order,
min_duration=config.min_duration,
max_duration=config.max_duration,
text_template=config.text_template,
material_requirements=config.material_requirements,
transition_effect=config.transition_effect,
config=config.config,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
self._configs[config.id] = config
return config
def update(self, config: TemplateClipConfig) -> TemplateClipConfig:
self._configs[config.id] = config
return config
def delete(self, config_id: str) -> bool:
return self._configs.pop(config_id, None) is not None
def delete_by_template(self, template_id: str) -> int:
ids = [cid for cid, c in self._configs.items() if c.template_id == template_id]
for cid in ids:
del self._configs[cid]
return len(ids)
def count(self, template_id: str) -> int:
return len([c for c in self._configs.values() if c.template_id == template_id])
# ---------------------------------------------------------------------------
# Service under test (inject stub repos)
# ---------------------------------------------------------------------------
def _make_service():
"""创建使用 stub 仓储的 EditTemplateService"""
from app.services.edit_template_service import EditTemplateService
db = MagicMock()
svc = EditTemplateService(db)
svc._template_repo = StubEditTemplateRepository()
svc._clip_config_repo = StubTemplateClipConfigRepository()
return svc
# ===========================================================================
# 模板 CRUD 测试
# ===========================================================================
class TestEditTemplateServiceCRUD:
"""模板 CRUD 测试"""
def test_create_template_success(self):
svc = _make_service()
t = svc.create_template(name="测试模板", description="描述")
assert t.name == "测试模板"
assert t.description == "描述"
assert t.status == EditTemplateStatus.ACTIVE
assert t.id
def test_create_template_strips_name(self):
svc = _make_service()
t = svc.create_template(name=" 测试 ")
assert t.name == "测试"
def test_create_template_empty_name_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="模板名称不能为空"):
svc.create_template(name=" ")
def test_create_template_duplicate_name_raises(self):
svc = _make_service()
svc.create_template(name="重复名称")
with pytest.raises(ValueError, match="模板名称已存在"):
svc.create_template(name="重复名称")
def test_create_template_inactive_name_can_reuse(self):
svc = _make_service()
t = svc.create_template(name="可复用")
svc.deactivate_template(t.id)
# inactive 的名称可以复用
t2 = svc.create_template(name="可复用")
assert t2.name == "可复用"
assert t2.id != t.id
def test_get_template(self):
svc = _make_service()
created = svc.create_template(name="查询测试")
fetched = svc.get_template(created.id)
assert fetched is not None
assert fetched.id == created.id
def test_get_template_returns_none(self):
svc = _make_service()
assert svc.get_template("nonexistent") is None
def test_get_template_or_raise(self):
svc = _make_service()
created = svc.create_template(name="查询测试")
fetched = svc.get_template_or_raise(created.id)
assert fetched.id == created.id
def test_get_template_or_raise_not_found(self):
svc = _make_service()
with pytest.raises(ValueError, match="模板不存在"):
svc.get_template_or_raise("nonexistent")
def test_list_templates(self):
svc = _make_service()
svc.create_template(name="模板1")
svc.create_template(name="模板2")
result = svc.list_templates()
assert len(result) == 2
def test_list_templates_with_type_filter(self):
svc = _make_service()
svc.create_template(name="默认", template_type="default")
svc.create_template(name="Vlog", template_type="vlog")
result = svc.list_templates(template_type="vlog")
assert len(result) == 1
assert result[0].name == "Vlog"
def test_list_templates_active_only(self):
svc = _make_service()
svc.create_template(name="活跃")
t2 = svc.create_template(name="停用")
svc.deactivate_template(t2.id)
result = svc.list_templates(active_only=True)
assert len(result) == 1
assert result[0].name == "活跃"
def test_count_templates(self):
svc = _make_service()
svc.create_template(name="模板1")
svc.create_template(name="模板2")
assert svc.count_templates() == 2
def test_update_template_name(self):
svc = _make_service()
t = svc.create_template(name="原名")
updated = svc.update_template(t.id, name="新名")
assert updated.name == "新名"
def test_update_template_duplicate_name_raises(self):
svc = _make_service()
svc.create_template(name="已存在")
t2 = svc.create_template(name="另一个")
with pytest.raises(ValueError, match="模板名称已存在"):
svc.update_template(t2.id, name="已存在")
def test_update_template_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="模板不存在"):
svc.update_template("nonexistent", name="新名")
def test_deactivate_template(self):
svc = _make_service()
t = svc.create_template(name="要停用的")
result = svc.deactivate_template(t.id)
assert result.status == EditTemplateStatus.INACTIVE
def test_deactivate_template_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="模板不存在"):
svc.deactivate_template("nonexistent")
# ===========================================================================
# 片段配置管理测试
# ===========================================================================
class TestClipConfigManagement:
"""片段配置管理测试"""
def test_create_clip_config(self):
svc = _make_service()
t = svc.create_template(name="模板")
cfg = svc.create_clip_config(
template_id=t.id,
clip_type=ClipType.INTRO,
order=0,
min_duration=1.0,
max_duration=5.0,
)
assert cfg.template_id == t.id
assert cfg.clip_type == ClipType.INTRO
assert cfg.order == 0
assert cfg.min_duration == 1.0
assert cfg.max_duration == 5.0
def test_create_clip_config_with_string_clip_type(self):
svc = _make_service()
t = svc.create_template(name="模板")
cfg = svc.create_clip_config(
template_id=t.id,
clip_type="main",
order=1,
)
assert cfg.clip_type == ClipType.MAIN
def test_list_clip_configs(self):
svc = _make_service()
t = svc.create_template(name="模板")
svc.create_clip_config(t.id, ClipType.INTRO, 0)
svc.create_clip_config(t.id, ClipType.MAIN, 1)
svc.create_clip_config(t.id, ClipType.OUTRO, 2)
result = svc.list_clip_configs(t.id)
assert len(result) == 3
# 按 order 排序
assert result[0].order == 0
assert result[1].order == 1
assert result[2].order == 2
def test_list_clip_configs_by_type(self):
svc = _make_service()
t = svc.create_template(name="模板")
svc.create_clip_config(t.id, ClipType.INTRO, 0)
svc.create_clip_config(t.id, ClipType.MAIN, 1)
result = svc.list_clip_configs(t.id, clip_type=ClipType.MAIN)
assert len(result) == 1
assert result[0].clip_type == ClipType.MAIN
def test_get_clip_config(self):
svc = _make_service()
t = svc.create_template(name="模板")
cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0)
fetched = svc.get_clip_config(cfg.id)
assert fetched is not None
assert fetched.id == cfg.id
def test_get_clip_config_not_found(self):
svc = _make_service()
assert svc.get_clip_config("nonexistent") is None
def test_get_clip_config_or_raise(self):
svc = _make_service()
with pytest.raises(ValueError, match="片段配置不存在"):
svc.get_clip_config_or_raise("nonexistent")
def test_update_clip_config(self):
svc = _make_service()
t = svc.create_template(name="模板")
cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0, min_duration=1.0)
updated = svc.update_clip_config(cfg.id, min_duration=2.0, max_duration=10.0)
assert updated.min_duration == 2.0
assert updated.max_duration == 10.0
def test_delete_clip_config(self):
svc = _make_service()
t = svc.create_template(name="模板")
cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0)
assert svc.delete_clip_config(cfg.id) is True
assert svc.get_clip_config(cfg.id) is None
def test_delete_clip_config_not_found(self):
svc = _make_service()
assert svc.delete_clip_config("nonexistent") is False
def test_reorder_clip_configs(self):
svc = _make_service()
t = svc.create_template(name="模板")
c1 = svc.create_clip_config(t.id, ClipType.INTRO, 0)
c2 = svc.create_clip_config(t.id, ClipType.MAIN, 1)
c3 = svc.create_clip_config(t.id, ClipType.OUTRO, 2)
# 反转顺序
reordered = svc.reorder_clip_configs(t.id, [c3.id, c2.id, c1.id])
assert len(reordered) == 3
assert reordered[0].id == c3.id
assert reordered[0].order == 0
assert reordered[1].id == c2.id
assert reordered[1].order == 1
assert reordered[2].id == c1.id
assert reordered[2].order == 2
def test_reorder_clip_configs_mismatch_raises(self):
svc = _make_service()
t = svc.create_template(name="模板")
c1 = svc.create_clip_config(t.id, ClipType.INTRO, 0)
svc.create_clip_config(t.id, ClipType.MAIN, 1)
with pytest.raises(ValueError, match="配置 ID 列表与模板下的配置不匹配"):
svc.reorder_clip_configs(t.id, [c1.id]) # 缺少一个
# ===========================================================================
# 复合查询测试
# ===========================================================================
class TestCompositeQueries:
"""复合查询测试"""
def test_get_template_with_configs(self):
svc = _make_service()
t = svc.create_template(name="模板")
svc.create_clip_config(t.id, ClipType.INTRO, 0)
svc.create_clip_config(t.id, ClipType.MAIN, 1)
result = svc.get_template_with_configs(t.id)
assert result["template"].id == t.id
assert len(result["clip_configs"]) == 2
def test_get_template_with_configs_not_found(self):
svc = _make_service()
with pytest.raises(ValueError, match="模板不存在"):
svc.get_template_with_configs("nonexistent")
def test_get_template_with_configs_empty(self):
svc = _make_service()
t = svc.create_template(name="空模板")
result = svc.get_template_with_configs(t.id)
assert len(result["clip_configs"]) == 0
# ===========================================================================
# Stub Repositories for EditPlan (save_as_template 测试用)
# ===========================================================================
class StubEditPlanRepository:
"""内存中的 EditPlan 仓储 stub"""
def __init__(self) -> None:
self._plans: dict[str, EditPlan] = {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"plan-{self._counter:03d}"
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
class StubEditPlanClipRepository:
"""内存中的 EditPlanClip 仓储 stub"""
def __init__(self) -> None:
self._clips: dict[str, EditPlanClip] = {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"clip-{self._counter:03d}"
def list_by_plan(
self,
plan_id: str,
*,
status: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[EditPlanClip]:
items = [c for c in self._clips.values() if c.plan_id == plan_id]
if status:
items = [c for c in items if c.status.value == status]
items.sort(key=lambda c: c.order)
return items[skip : skip + limit]
def create(self, clip: EditPlanClip) -> EditPlanClip:
if not clip.id:
clip.id = self._next_id()
self._clips[clip.id] = clip
return clip
def _make_service_with_plan_stubs():
"""创建使用 stub 仓储的 EditTemplateService(含 plan 相关 stub"""
from app.services.edit_template_service import EditTemplateService
db = MagicMock()
svc = EditTemplateService(db)
svc._template_repo = StubEditTemplateRepository()
svc._clip_config_repo = StubTemplateClipConfigRepository()
svc._plan_repo = StubEditPlanRepository()
svc._plan_clip_repo = StubEditPlanClipRepository()
return svc
def _make_test_plan_with_clips(svc, *, clip_count: int = 3, plan_config=None):
"""辅助方法:创建一个带片段的测试计划,返回 plan 对象"""
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
plan = EditPlan.create(
template_id="tpl-source",
name="我的剪辑计划",
config=plan_config or {"editing_mode": "one_take", "theme": "minimal"},
project_id="proj-001",
created_by_user_id="user-001",
)
plan.id = "plan-test-001"
svc._plan_repo.create(plan)
for i in range(clip_count):
clip = EditPlanClip.create(
plan_id=plan.id,
clip_type=ClipType.MAIN.value,
order=i,
asset_id=f"asset-{i:03d}",
text_content=f"片段{i}的文案",
duration=10.0 + i * 5,
transition_effect="cut" if i == 0 else "fade",
playback_speed=1.0 if i == 0 else 1.5,
config={"filter": "vivid"} if i == 1 else {},
)
svc._plan_clip_repo.create(clip)
return plan
# ===========================================================================
# 保存为模板测试
# ===========================================================================
class TestSavePlanAsTemplate:
"""从剪辑计划保存为模板测试"""
def test_basic_save_as_template(self):
"""基础场景:将有3个片段的计划保存为模板"""
svc = _make_service_with_plan_stubs()
plan = _make_test_plan_with_clips(svc, clip_count=3)
result = svc.save_plan_as_template(plan.id, name="我的自定义模板")
assert result["template"].name == "我的自定义模板"
assert result["template"].template_type == "custom"
assert result["template"].editing_mode == "one_take"
assert result["template"].status == EditTemplateStatus.ACTIVE
assert len(result["clip_configs"]) == 3
def test_clip_configs_correctly_converted(self):
"""片段正确转换为模板片段配置"""
svc = _make_service_with_plan_stubs()
plan = _make_test_plan_with_clips(svc, clip_count=2)
result = svc.save_plan_as_template(plan.id, name="转换测试模板")
configs = result["clip_configs"]
configs.sort(key=lambda c: c.order)
# 第0个片段
assert configs[0].clip_type == ClipType.MAIN
assert configs[0].order == 0
assert configs[0].min_duration == 10.0
assert configs[0].max_duration == 10.0
assert configs[0].text_template == "片段0的文案"
assert configs[0].transition_effect.value == "cut"
# playback_speed=1.0 不存
assert "playback_speed" not in configs[0].config
# 第1个片段
assert configs[1].order == 1
assert configs[1].min_duration == 15.0
assert configs[1].max_duration == 15.0
assert configs[1].transition_effect.value == "fade"
# playback_speed=1.5 存入config
assert configs[1].config.get("playback_speed") == 1.5
# config 中的 filter 保留
assert configs[1].config.get("filter") == "vivid"
def test_no_asset_id_in_template(self):
"""模板不保留具体素材ID"""
svc = _make_service_with_plan_stubs()
plan = _make_test_plan_with_clips(svc, clip_count=2)
result = svc.save_plan_as_template(plan.id, name="素材剥离测试")
for cfg in result["clip_configs"]:
# 模板片段配置没有 asset_id 字段
assert not hasattr(cfg, "asset_id") or not getattr(cfg, "asset_id", "")
# config 中也不应有素材相关字段
assert "asset_info" not in cfg.config
assert "source_asset_id" not in cfg.config
def test_template_config_stripped_of_runtime_fields(self):
"""模板config剥离运行时字段"""
svc = _make_service_with_plan_stubs()
plan_config = {
"editing_mode": "one_take",
"theme": "cinematic",
"asset_ids": ["a1", "a2"],
"source_edit_plan_id": "old-plan",
"generation_task_id": "task-123",
}
plan = _make_test_plan_with_clips(svc, clip_count=1, plan_config=plan_config)
result = svc.save_plan_as_template(plan.id, name="配置剥离测试")
tpl_config = result["template"].config
assert tpl_config.get("theme") == "cinematic"
assert "asset_ids" not in tpl_config
assert "source_edit_plan_id" not in tpl_config
assert "generation_task_id" not in tpl_config
def test_plan_not_found_raises_error(self):
"""计划不存在时报错"""
svc = _make_service_with_plan_stubs()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.save_plan_as_template("nonexistent-plan", name="不存在的计划")
def test_empty_name_raises_error(self):
"""模板名称为空时报错"""
svc = _make_service_with_plan_stubs()
plan = _make_test_plan_with_clips(svc, clip_count=1)
with pytest.raises(ValueError, match="模板名称不能为空"):
svc.save_plan_as_template(plan.id, name=" ")
def test_duplicate_name_raises_error(self):
"""模板名称重复时报错"""
svc = _make_service_with_plan_stubs()
svc.create_template(name="重名模板")
plan = _make_test_plan_with_clips(svc, clip_count=1)
with pytest.raises(ValueError, match="模板名称已存在"):
svc.save_plan_as_template(plan.id, name="重名模板")
def test_save_zero_clip_plan(self):
"""零片段计划也能保存为模板"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
plan = EditPlan.create(
template_id="tpl-source",
name="空计划",
config={"editing_mode": "one_take"},
)
plan.id = "plan-empty"
svc._plan_repo.create(plan)
result = svc.save_plan_as_template(plan.id, name="空模板")
assert result["template"].name == "空模板"
assert len(result["clip_configs"]) == 0
def test_custom_description_and_type(self):
"""自定义描述和模板类型"""
svc = _make_service_with_plan_stubs()
plan = _make_test_plan_with_clips(svc, clip_count=1)
result = svc.save_plan_as_template(
plan.id,
name="自定义模板",
description="这是一个测试模板",
template_type="vlog",
)
assert result["template"].description == "这是一个测试模板"
assert result["template"].template_type == "vlog"
def test_unknown_transition_effect_falls_back_to_cut(self):
"""未知转场效果回退到cut"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
plan = EditPlan.create(template_id="tpl-src", name="转场测试计划")
plan.id = "plan-transition-test"
svc._plan_repo.create(plan)
clip = EditPlanClip.create(
plan_id=plan.id,
clip_type="main",
order=0,
duration=10.0,
transition_effect="weird_effect_that_does_not_exist",
)
svc._plan_clip_repo.create(clip)
result = svc.save_plan_as_template(plan.id, name="转场兼容模板")
assert result["clip_configs"][0].transition_effect.value == "cut"
def test_unknown_clip_type_falls_back_to_main(self):
"""未知片段类型回退到main"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
plan = EditPlan.create(template_id="tpl-src", name="类型测试计划")
plan.id = "plan-type-test"
svc._plan_repo.create(plan)
clip = EditPlanClip.create(
plan_id=plan.id,
clip_type="unknown_clip_type",
order=0,
duration=10.0,
)
svc._plan_clip_repo.create(clip)
result = svc.save_plan_as_template(plan.id, name="类型兼容模板")
assert result["clip_configs"][0].clip_type == ClipType.MAIN