Files
xiaoxia-saas/tests/unit/test_edit_template_service.py
T
灵应 8935196fcd
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
style: 后端代码black格式化
2026-07-03 18:49:54 +08:00

466 lines
16 KiB
Python

"""
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 Any, List, 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 packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
# ---------------------------------------------------------------------------
# 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()
t1 = 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