"""模板片段配置读取路径测试(#1774). 收敛后模板读取走单一数据源,不再有"新表抛异常→降级查旧表→再降级查 segments" 的异常控制流: - ``EditTemplateService.list_clip_configs_for_editor`` 显式判定模板归属/存在性: 1. 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True)→ 直接读 ``template_clip_configs``; 2. 全局模板在新表 ``edit_templates``(无 user_id)→ 直接读 ``template_clip_configs``; 3. 两表都没有 → 抛 ``TemplateNotFoundError``(路由层映射 404)。 - ``_get_template_segments`` 仅做配置→(order, min, max) 的映射与排序, 模板存在但无配置返回空列表(路由层映射 422)。 使用真实 SQLite 内存库 + 真实仓储,验证端到端读路径不抛 ``ValueError: 模板不存在``。 """ from __future__ import annotations import os import sys from pathlib import Path 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") sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) import pytest # noqa: E402 from sqlalchemy import create_engine # noqa: E402 from sqlalchemy.orm import sessionmaker # noqa: E402 from packages.adapters.sqlalchemy_impl.models import ( # noqa: E402 Base, EditTemplateModel, TemplateClipConfigModel, TemplateModel, ) DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION USER_ID = "user-001" OTHER_USER_ID = "user-002" # --------------------------------------------------------------------------- # 真实内存 DB fixture # --------------------------------------------------------------------------- def _make_session(): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) return sessionmaker(bind=engine)() def _seed_legacy_template(session, template_id: str, user_id: str, *, active: bool = True, clip_count: int = 3): """创建旧表 templates 模板(+ template_clip_configs 片段配置)。""" session.add( TemplateModel( id=template_id, user_id=user_id, name=f"模板-{template_id}", mode="one_take", is_active=active, ) ) for order in range(clip_count): session.add( TemplateClipConfigModel( id=f"cc-{template_id}-{order}", template_id=template_id, clip_type="main", order=order, min_duration=5.0, max_duration=8.0, ) ) session.commit() def _seed_global_template(session, template_id: str, *, status: str = "active", clip_count: int = 2): """创建新表 edit_templates 全局模板(+ template_clip_configs 片段配置)。""" session.add( EditTemplateModel( id=template_id, name=f"全局模板-{template_id}", template_type="default", editing_mode="one_take", status=status, ) ) for order in range(clip_count): session.add( TemplateClipConfigModel( id=f"gcc-{template_id}-{order}", template_id=template_id, clip_type="main", order=order, min_duration=3.0, max_duration=6.0, ) ) session.commit() # --------------------------------------------------------------------------- # Service 读路径测试 # --------------------------------------------------------------------------- class TestListClipConfigsForEditor: """list_clip_configs_for_editor 单一数据源 + 归属/存在性判定。""" def test_legacy_user_template_returns_configs(self): """用户自建模板(templates 表 + 3 条 clip_configs)→ 正常返回,不抛异常。""" from app.services.edit_template_service import EditTemplateService session = _make_session() _seed_legacy_template(session, "tmpl-legacy", USER_ID, clip_count=3) svc = EditTemplateService(session) configs = svc.list_clip_configs_for_editor("tmpl-legacy", USER_ID) assert len(configs) == 3 assert [c.order for c in configs] == [0, 1, 2] assert all(c.min_duration == 5.0 for c in configs) def test_missing_template_raises_not_found(self): """模板不存在(两表都没有)→ TemplateNotFoundError。""" from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError session = _make_session() svc = EditTemplateService(session) with pytest.raises(TemplateNotFoundError): svc.list_clip_configs_for_editor("tmpl-not-exist", USER_ID) def test_other_users_template_raises_not_found(self): """他人模板(user_id 不匹配)→ TemplateNotFoundError(归属校验)。""" from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError session = _make_session() _seed_legacy_template(session, "tmpl-owner", OTHER_USER_ID, clip_count=3) svc = EditTemplateService(session) with pytest.raises(TemplateNotFoundError): svc.list_clip_configs_for_editor("tmpl-owner", USER_ID) def test_deleted_legacy_template_raises_not_found(self): """已软删除(is_active=False)的旧表模板 → TemplateNotFoundError。""" from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError session = _make_session() _seed_legacy_template(session, "tmpl-deleted", USER_ID, active=False, clip_count=3) svc = EditTemplateService(session) with pytest.raises(TemplateNotFoundError): svc.list_clip_configs_for_editor("tmpl-deleted", USER_ID) def test_legacy_template_without_configs_returns_empty(self): """模板存在且归属正确但无片段配置 → 返回空列表(不抛异常,路由层映射 422)。""" from app.services.edit_template_service import EditTemplateService session = _make_session() _seed_legacy_template(session, "tmpl-noconfig", USER_ID, clip_count=0) svc = EditTemplateService(session) configs = svc.list_clip_configs_for_editor("tmpl-noconfig", USER_ID) assert configs == [] def test_global_template_returns_configs(self): """新表 edit_templates 全局模板(无 user_id)→ 任意用户可读,正常返回。""" from app.services.edit_template_service import EditTemplateService session = _make_session() _seed_global_template(session, "tmpl-global", clip_count=2) svc = EditTemplateService(session) configs = svc.list_clip_configs_for_editor("tmpl-global", USER_ID) assert len(configs) == 2 assert [c.order for c in configs] == [0, 1] def test_normal_legacy_request_does_not_raise_valueerror(self): """正常旧表模板请求绝不在读路径抛 ValueError: 模板不存在(回归保护)。""" import logging from app.services.edit_template_service import EditTemplateService session = _make_session() _seed_legacy_template(session, "tmpl-ok", USER_ID, clip_count=3) svc = EditTemplateService(session) with pytest.MonkeyPatch.context() as mp: # 若读路径意外抛 ValueError 并被记录为异常堆栈,测试能感知 errors: list[str] = [] mp.setattr( logging.getLogger("app.services.edit_template_service"), "exception", lambda *a, **k: errors.append(str(a)), ) configs = svc.list_clip_configs_for_editor("tmpl-ok", USER_ID) assert len(configs) == 3 assert errors == [] # --------------------------------------------------------------------------- # _get_template_segments 映射测试 # --------------------------------------------------------------------------- def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0): cc = MagicMock() cc.order = order cc.min_duration = min_dur cc.max_duration = max_dur return cc class TestGetTemplateSegments: """_get_template_segments 仅做映射/排序,异常与空配置语义明确。""" def test_maps_and_sorts_configs(self): from app.api.routes.templates_editor.clips import _get_template_segments tpl_svc = MagicMock() tpl_svc.list_clip_configs_for_editor.return_value = [ _make_clip_config(2, 5.0, 10.0), _make_clip_config(0, 2.0, 4.0), _make_clip_config(1, 3.0, 6.0), ] result = _get_template_segments("tmpl-1", USER_ID, tpl_svc) assert [r[0] for r in result] == [0, 1, 2] assert result[0] == (0, 2.0, 4.0) assert result[2] == (2, 5.0, 10.0) tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", USER_ID) def test_empty_configs_returns_empty(self): from app.api.routes.templates_editor.clips import _get_template_segments tpl_svc = MagicMock() tpl_svc.list_clip_configs_for_editor.return_value = [] assert _get_template_segments("tmpl-1", USER_ID, tpl_svc) == [] def test_missing_template_propagates_not_found(self): from app.api.routes.templates_editor.clips import _get_template_segments from app.services.edit_template_service import TemplateNotFoundError tpl_svc = MagicMock() tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x") with pytest.raises(TemplateNotFoundError): _get_template_segments("tmpl-x", USER_ID, tpl_svc) def test_none_durations_use_default(self): from app.api.routes.templates_editor.clips import _get_template_segments tpl_svc = MagicMock() cc = MagicMock() cc.order = 0 cc.min_duration = None cc.max_duration = None tpl_svc.list_clip_configs_for_editor.return_value = [cc] result = _get_template_segments("tmpl-1", USER_ID, tpl_svc) assert result == [(0, DEFAULT_DUR, DEFAULT_DUR)]