7766ba1479
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""模板片段配置读取路径测试(#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)]
|