Files
xiaoxia-saas/tests/unit/test_edit_plan_service.py
xiaoxia 368baf683b
CI/CD Pipeline / Validate Code Quality And Tests (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 Lint (push) Has been cancelled
CI/CD Pipeline / Build & Push 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 Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
fix: StubGenerationTaskRepository补list_by_user_filtered方法 (#306)
2026-07-14 11:02:57 +08:00

639 lines
22 KiB
Python
Executable File

"""
EditPlanService 单元测试
覆盖(35+ 测试用例):
- 计划 CRUD:创建、查询、更新、删除
- 状态机流转:合法流转、非法流转、幂等流转
- 片段管理:创建、更新、删除、分配素材
- 渲染生成流程:can_generate、mark_clips_ready、get_generation_status
- 异常处理:不存在、参数校验
"""
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
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_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
# ---------------------------------------------------------------------------
# Stub Repositories
# ---------------------------------------------------------------------------
class StubEditPlanRepository:
"""内存中的 EditPlan 仓储 stub"""
def __init__(self) -> None:
self._plans: dict[str, EditPlan] = {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"plan-{self._counter:03d}"
def list_all(
self,
*,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
items = list(self._plans.values())
if status:
items = [p for p in items if p.status == status]
return items[skip : skip + limit]
def list_by_template(
self,
template_id: str,
*,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
items = [p for p in self._plans.values() if p.template_id == template_id]
if status:
items = [p for p in items if p.status == status]
return items[skip : skip + limit]
def get(self, plan_id: str) -> Optional[EditPlan]:
return self._plans.get(plan_id)
def create(self, plan: EditPlan) -> EditPlan:
if not plan.id:
plan = EditPlan(
id=self._next_id(),
template_id=plan.template_id,
name=plan.name,
status=plan.status,
total_duration=plan.total_duration,
config=plan.config,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
self._plans[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def delete(self, plan_id: str) -> bool:
return self._plans.pop(plan_id, None) is not None
def count(
self,
*,
status: Optional[EditPlanStatus] = None,
) -> int:
items = list(self._plans.values())
if status:
items = [p for p in items if p.status == status]
return len(items)
class StubEditPlanClipRepository:
"""内存中的 EditPlanClip 仓储 stub"""
def __init__(self) -> None:
self._clips: dict[str, EditPlanClip] = {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"clip-{self._counter:03d}"
def list_by_plan(
self,
plan_id: str,
*,
status: Optional[EditPlanClipStatus] = None,
skip: int = 0,
limit: int = 100,
) -> List[EditPlanClip]:
items = [c for c in self._clips.values() if c.plan_id == plan_id]
if status:
items = [c for c in items if c.status == status]
items.sort(key=lambda c: c.order)
return items[skip : skip + limit]
def get(self, clip_id: str) -> Optional[EditPlanClip]:
return self._clips.get(clip_id)
def create(self, clip: EditPlanClip) -> EditPlanClip:
if not clip.id:
clip = EditPlanClip(
id=self._next_id(),
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect,
status=clip.status,
config=clip.config,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
self._clips[clip.id] = clip
return clip
def update(self, clip: EditPlanClip) -> EditPlanClip:
self._clips[clip.id] = clip
return clip
def delete(self, clip_id: str) -> bool:
return self._clips.pop(clip_id, None) is not None
def delete_by_plan(self, plan_id: str) -> int:
ids = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
for cid in ids:
del self._clips[cid]
return len(ids)
def count(
self,
*,
plan_id: Optional[str] = None,
status: Optional[EditPlanClipStatus] = None,
) -> int:
items = list(self._clips.values())
if plan_id:
items = [c for c in items if c.plan_id == plan_id]
if status:
items = [c for c in items if c.status == status]
return len(items)
class StubGenerationTaskRepository:
"""内存中的 GenerationTask 仓储 stub"""
def __init__(self) -> None:
self._tasks: dict[str, Any] = {}
def get(self, task_id: str) -> Optional[Any]:
return self._tasks.get(task_id)
def create(self, task: Any) -> Any:
self._tasks[task.id] = task
return task
def count_pending_by_user(self, user_id: str) -> int:
return 0
def count_pending_total(self) -> int:
return 0
def list_by_user_filtered(
self,
user_id: str,
*,
status: str | None = None,
limit: int | None = None,
offset: int = 0,
) -> list:
"""按用户+状态筛选任务列表(stub实现)。"""
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
if status:
items = [t for t in items if str(t.status) == status]
# 按创建时间倒序
items.sort(key=lambda t: t.created_at or "", reverse=True)
if offset:
items = items[offset:]
if limit is not None:
items = items[:limit]
return items
def count_by_user_filtered(
self,
user_id: str,
*,
status: str | None = None,
) -> int:
"""按用户+状态筛选计数(stub实现)。"""
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
if status:
items = [t for t in items if str(t.status) == status]
return len(items)
def list_by_project_filtered(
self,
project_id: str,
*,
status: str | None = None,
limit: int | None = None,
offset: int = 0,
) -> list:
"""按项目+状态筛选任务列表(stub实现)。"""
items = [t for t in self._tasks.values() if t.project_id == project_id]
if status:
items = [t for t in items if str(t.status) == status]
# 按创建时间倒序
items.sort(key=lambda t: t.created_at or "", reverse=True)
if offset:
items = items[offset:]
if limit is not None:
items = items[:limit]
return items
def count_by_project_filtered(
self,
project_id: str,
*,
status: str | None = None,
) -> int:
"""按项目+状态筛选计数(stub实现)。"""
items = [t for t in self._tasks.values() if t.project_id == project_id]
if status:
items = [t for t in items if str(t.status) == status]
return len(items)
# ---------------------------------------------------------------------------
# Service factory
# ---------------------------------------------------------------------------
def _make_service():
"""创建使用 stub 仓储的 EditPlanService"""
from app.services.edit_plan_service import EditPlanService
db = MagicMock()
svc = EditPlanService(db)
svc._plan_repo = StubEditPlanRepository()
svc._clip_repo = StubEditPlanClipRepository()
svc._generation_task_repo = StubGenerationTaskRepository()
return svc
# ===========================================================================
# 计划 CRUD 测试
# ===========================================================================
class TestEditPlanServiceCRUD:
"""计划 CRUD 测试"""
def test_create_plan_success(self):
svc = _make_service()
plan = svc.create_plan(template_id="tpl-001", name="测试计划")
assert plan.name == "测试计划"
assert plan.template_id == "tpl-001"
assert plan.status == EditPlanStatus.DRAFT
assert plan.id
def test_get_plan(self):
svc = _make_service()
created = svc.create_plan("tpl-001", "查询测试")
fetched = svc.get_plan(created.id)
assert fetched is not None
assert fetched.id == created.id
def test_get_plan_returns_none(self):
svc = _make_service()
assert svc.get_plan("nonexistent") is None
def test_get_plan_or_raise(self):
svc = _make_service()
created = svc.create_plan("tpl-001", "查询测试")
fetched = svc.get_plan_or_raise(created.id)
assert fetched.id == created.id
def test_get_plan_or_raise_not_found(self):
svc = _make_service()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.get_plan_or_raise("nonexistent")
def test_list_plans(self):
svc = _make_service()
svc.create_plan("tpl-001", "计划1")
svc.create_plan("tpl-001", "计划2")
result = svc.list_plans()
assert len(result) == 2
def test_list_plans_by_template(self):
svc = _make_service()
svc.create_plan("tpl-001", "计划1")
svc.create_plan("tpl-002", "计划2")
result = svc.list_plans(template_id="tpl-001")
assert len(result) == 1
assert result[0].name == "计划1"
def test_list_plans_by_status(self):
svc = _make_service()
p1 = svc.create_plan("tpl-001", "计划1")
svc.create_plan("tpl-001", "计划2")
svc.transition_status(p1.id, EditPlanStatus.EDITING)
result = svc.list_plans(status=EditPlanStatus.EDITING)
assert len(result) == 1
def test_count_plans(self):
svc = _make_service()
svc.create_plan("tpl-001", "计划1")
svc.create_plan("tpl-001", "计划2")
assert svc.count_plans() == 2
def test_update_plan_name(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "原名")
updated = svc.update_plan(p.id, name="新名")
assert updated.name == "新名"
def test_update_plan_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.update_plan("nonexistent", name="新名")
def test_delete_plan(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "要删除")
assert svc.delete_plan(p.id) is True
assert svc.get_plan(p.id) is None
def test_delete_plan_not_found(self):
svc = _make_service()
assert svc.delete_plan("nonexistent") is False
def test_delete_plan_also_deletes_clips(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "带片段")
svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
assert svc.count_clips(p.id) == 2
svc.delete_plan(p.id)
# 片段应被一并删除
assert svc.count_clips(p.id) == 0
# ===========================================================================
# 状态机流转测试
# ===========================================================================
class TestStatusTransitions:
"""状态机流转测试"""
def test_transition_draft_to_editing(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
result = svc.transition_status(p.id, EditPlanStatus.EDITING)
assert result.status == EditPlanStatus.EDITING
def test_transition_editing_to_rendering(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
result = svc.transition_status(p.id, EditPlanStatus.RENDERING)
assert result.status == EditPlanStatus.RENDERING
def test_transition_rendering_to_completed(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
result = svc.transition_status(p.id, EditPlanStatus.COMPLETED)
assert result.status == EditPlanStatus.COMPLETED
def test_transition_rendering_to_failed(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
result = svc.transition_status(p.id, EditPlanStatus.FAILED)
assert result.status == EditPlanStatus.FAILED
def test_transition_failed_to_draft(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.FAILED)
result = svc.transition_status(p.id, EditPlanStatus.DRAFT)
assert result.status == EditPlanStatus.DRAFT
def test_transition_idempotent(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
result = svc.transition_status(p.id, EditPlanStatus.DRAFT)
assert result.status == EditPlanStatus.DRAFT
def test_transition_illegal_raises(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
# draft → completed 是非法的
with pytest.raises(ValueError):
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
def test_transition_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.transition_status("nonexistent", EditPlanStatus.EDITING)
# ===========================================================================
# 片段管理测试
# ===========================================================================
class TestClipManagement:
"""片段管理测试"""
def test_create_clip(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "intro", 0)
assert clip.plan_id == p.id
assert clip.clip_type == "intro"
assert clip.order == 0
assert clip.status == EditPlanClipStatus.PENDING
def test_create_clip_plan_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.create_clip("nonexistent", "intro", 0)
def test_list_clips(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
svc.create_clip(p.id, "outro", 2)
result = svc.list_clips(p.id)
assert len(result) == 3
def test_list_clips_by_status(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
c1 = svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
# assign_asset 只设置 asset_id,需要额外 mark_ready 才变 ready
svc.assign_asset(c1.id, "asset-001")
# 手动 mark_ready
clip_obj = svc._clip_repo.get(c1.id)
clip_obj.mark_ready()
svc._clip_repo.update(clip_obj)
result = svc.list_clips(p.id, status=EditPlanClipStatus.READY)
assert len(result) == 1
def test_count_clips(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
assert svc.count_clips(p.id) == 2
def test_get_clip(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "intro", 0)
fetched = svc.get_clip(clip.id)
assert fetched is not None
assert fetched.id == clip.id
def test_get_clip_or_raise_not_found(self):
svc = _make_service()
with pytest.raises(ValueError, match="片段不存在"):
svc.get_clip_or_raise("nonexistent")
def test_update_clip(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "intro", 0, duration=3.0)
updated = svc.update_clip(clip.id, duration=5.0)
assert updated.duration == 5.0
def test_update_clip_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="片段不存在"):
svc.update_clip("nonexistent", duration=5.0)
def test_assign_asset(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "intro", 0)
result = svc.assign_asset(clip.id, "asset-001")
assert result.asset_id == "asset-001"
# assign_asset 只设置 asset_id,不改变状态(状态需 mark_ready 流转)
assert result.status == EditPlanClipStatus.PENDING
def test_assign_asset_empty_raises(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "intro", 0)
with pytest.raises(ValueError):
svc.assign_asset(clip.id, "")
def test_delete_clip(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "intro", 0)
assert svc.delete_clip(clip.id) is True
assert svc.get_clip(clip.id) is None
def test_delete_all_clips(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
count = svc.delete_all_clips(p.id)
assert count == 2
assert svc.count_clips(p.id) == 0
# ===========================================================================
# 渲染生成流程测试
# ===========================================================================
class TestGenerationWorkflow:
"""渲染生成流程测试"""
def test_can_generate_editing_with_clips(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.create_clip(p.id, "intro", 0)
can, reason = svc.can_generate(p.id)
assert can is True
assert reason == ""
def test_can_generate_draft_fails(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
can, reason = svc.can_generate(p.id)
assert can is False
assert "编辑" in reason or "模板" in reason
def test_can_generate_no_clips_fails(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
can, reason = svc.can_generate(p.id)
assert can is False
assert "请先添加片段后再生成视频" in reason
def test_mark_clips_ready(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
count = svc.mark_clips_ready(p.id)
assert count == 2
# 验证所有片段都是 ready 状态
clips = svc.list_clips(p.id)
for c in clips:
assert c.status == EditPlanClipStatus.READY
def test_get_plan_with_clips(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
svc.create_clip(p.id, "main", 1)
result = svc.get_plan_with_clips(p.id)
assert result["plan"].id == p.id
assert len(result["clips"]) == 2
def test_get_plan_with_clips_not_found(self):
svc = _make_service()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.get_plan_with_clips("nonexistent")
def test_get_generation_status(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "intro", 0)
result = svc.get_generation_status(p.id)
assert result["plan"].id == p.id
assert len(result["clips"]) == 1
assert result["generation_task_id"] is None
def test_update_plan_config(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
updated = svc.update_plan_config(p.id, {"key2": "val2"})
assert updated.config["key1"] == "val1"
assert updated.config["key2"] == "val2"
def test_update_plan_config_overwrites(self):
svc = _make_service()
p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
updated = svc.update_plan_config(p.id, {"key1": "new_val"})
assert updated.config["key1"] == "new_val"