""" 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