1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
- 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL
2. pyflakes 警告清零 (apps/ + packages/ + tests/):
- 移除 17 处未使用的 import (F401)
- 修复 26 处未使用的局部变量 (F841):
* 有副作用的赋值转为裸调用
* 无副作用的赋值直接删除
- 修复 1 处未使用的异常变量 (F841)
- 修复 1 处空 except 块
3. 测试文件冗余清理:
- 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
- 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
466 lines
16 KiB
Python
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 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
|