Files
xiaoxia-saas/tests/unit/test_edit_plan_generation_api.py
T
xiaoxia ad671d94c5
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m25s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m56s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 6m40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m35s
fix(voice-clone): source_audio_url 不做预签名转换,原样返回用户输入
fix(voice-clone): source_audio_url 不做预签名转换,原样返回用户输入
2026-07-11 19:50:30 +08:00

683 lines
24 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""剪辑计划生成 API 单元测试 — Phase 8 任务 2.05.
覆盖 2 个新端点:
POST /api/v1/edit-plans/{id}/generate — 触发剪辑渲染生成
GET /api/v1/edit-plans/{id}/generation-status — 查询生成进度
使用 FastAPI TestClient + Stub Repository + dependency_overrides.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, 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
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
# ── Stub Repositories ─────────────────────────────────────────────────────────
class StubEditPlanRepository:
"""内存中模拟 EditPlan 仓储"""
def __init__(self) -> None:
self._store: dict[str, EditPlan] = {}
def list_all(
self,
*,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> list[EditPlan]:
items = list(self._store.values())
if status:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
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._store.values() if p.template_id == template_id]
if status:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def get(self, plan_id: str) -> Optional[EditPlan]:
return self._store.get(plan_id)
def create(self, plan: EditPlan) -> EditPlan:
self._store[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
if plan.id not in self._store:
raise ValueError(f"EditPlan {plan.id} not found")
self._store[plan.id] = plan
return plan
def delete(self, plan_id: str) -> bool:
if plan_id in self._store:
del self._store[plan_id]
return True
return False
def count(self, *, status: Optional[EditPlanStatus] = None) -> int:
items = list(self._store.values())
if status:
items = [p for p in items if p.status == status]
return len(items)
class StubEditPlanClipRepository:
"""内存中模拟 EditPlanClip 仓储"""
def __init__(self) -> None:
self._store: dict[str, EditPlanClip] = {}
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._store.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._store.get(clip_id)
def create(self, clip: EditPlanClip) -> EditPlanClip:
self._store[clip.id] = clip
return clip
def update(self, clip: EditPlanClip) -> EditPlanClip:
if clip.id not in self._store:
raise ValueError(f"EditPlanClip {clip.id} not found")
self._store[clip.id] = clip
return clip
def delete(self, clip_id: str) -> bool:
if clip_id in self._store:
del self._store[clip_id]
return True
return False
def delete_by_plan(self, plan_id: str) -> int:
to_delete = [c.id for c in self._store.values() if c.plan_id == plan_id]
for cid in to_delete:
del self._store[cid]
return len(to_delete)
def count(
self,
plan_id: Optional[str] = None,
*,
status: Optional[EditPlanClipStatus] = None,
) -> int:
items = list(self._store.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 仓储"""
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def create(self, task: Any) -> Any:
self._store[task.id] = task
return task
def get(self, task_id: str) -> Optional[Any]:
return self._store.get(task_id)
def update(self, task: Any) -> Any:
if task.id not in self._store:
raise ValueError(f"GenerationTask {task.id} not found")
self._store[task.id] = task
return task
def list_by_project(self, project_id: str) -> list[Any]:
return [t for t in self._store.values() if t.project_id == project_id]
def list_by_user(self, user_id: str) -> list[Any]:
return [t for t in self._store.values() if t.created_by_user_id == user_id]
def count_by_user(self, user_id: str) -> int:
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
def count_pending_by_user(self, user_id: str) -> int:
return len(
[
t
for t in self._store.values()
if t.created_by_user_id == user_id and getattr(t, "status", "") == "pending"
]
)
def count_pending_total(self) -> int:
return len([t for t in self._store.values() if getattr(t, "status", "") == "pending"])
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
items.sort(key=lambda t: t.created_at, reverse=True)
return items[:limit]
# ── Fixtures ──────────────────────────────────────────────────────────────────
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
@pytest.fixture
def plan_repo() -> StubEditPlanRepository:
return StubEditPlanRepository()
@pytest.fixture
def clip_repo() -> StubEditPlanClipRepository:
return StubEditPlanClipRepository()
@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
return StubGenerationTaskRepository()
@pytest.fixture
def app(
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
gen_task_repo: StubGenerationTaskRepository,
) -> FastAPI:
"""构建测试 FastAPI 应用,注入 Stub Repository"""
import app.services.edit_plan_service as service_module
from app.api.routes.edit_plans import router
from app.auth import get_current_user
from app.dependencies import get_db_session
# 替换 Repository 类
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
service_module.SQLAlchemyEditPlanRepository = lambda session: plan_repo
service_module.SQLAlchemyEditPlanClipRepository = lambda session: clip_repo
service_module.SQLAlchemyGenerationTaskRepository = lambda session: gen_task_repo
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/edit-plans")
def override_get_db_session():
yield MagicMock()
def override_get_current_user():
return FakeAuthenticatedUser()
test_app.dependency_overrides[get_db_session] = override_get_db_session
test_app.dependency_overrides[get_current_user] = override_get_current_user
yield test_app
# 恢复
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
test_app.dependency_overrides.clear()
@pytest.fixture
def client(app: FastAPI) -> TestClient:
return TestClient(app)
def _make_plan(
name: str = "测试计划",
template_id: str = "tmpl-001",
status: EditPlanStatus = EditPlanStatus.DRAFT,
**kwargs: Any,
) -> EditPlan:
plan = EditPlan.create(template_id=template_id, name=name, **kwargs)
plan.status = status
return plan
def _make_clip(
plan_id: str,
clip_type: str = "MAIN",
order: int = 1,
asset_id: str = "assets/video.mp4",
status: EditPlanClipStatus = EditPlanClipStatus.PENDING,
**kwargs: Any,
) -> EditPlanClip:
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=order,
asset_id=asset_id,
**kwargs,
)
clip.status = status
return clip
# ── POST /api/v1/edit-plans/{id}/generate ─────────────────────────────────────
class TestGeneratePlan:
def test_generate_success(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""editing 状态 + pending 片段 → 触发成功"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 200
data = resp.json()
assert data["plan_id"] == plan.id
assert data["plan_status"] == "rendering"
assert data["clip_count"] == 1
assert "generation_task_id" in data
# 验证计划状态已更新
updated = plan_repo.get(plan.id)
assert updated.status == EditPlanStatus.RENDERING
# 验证片段状态已更新为 ready
updated_clip = clip_repo.get(clip.id)
assert updated_clip.status == EditPlanClipStatus.READY
def test_generate_not_found(self, client: TestClient) -> None:
"""计划不存在 → 404"""
resp = client.post("/api/v1/edit-plans/nonexistent/generate")
assert resp.status_code == 404
assert "不存在" in resp.json()["detail"]
def test_generate_draft_auto_transition_to_editing(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
) -> None:
"""draft 状态自动转 editing(自动兜底),然后因 0 片段报错"""
plan = _make_plan(status=EditPlanStatus.DRAFT)
plan_repo.create(plan)
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
# draft 自动转 editing,但没有片段所以还是 400
assert resp.status_code == 400
assert "请先添加片段后再生成视频" in resp.json()["detail"]
# 验证状态已自动转为 editing
updated_plan = plan_repo.get(plan.id)
assert updated_plan is not None
assert updated_plan.status == EditPlanStatus.EDITING
def test_generate_wrong_status_rendering(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
) -> None:
"""rendering 状态 → 400"""
plan = _make_plan(status=EditPlanStatus.RENDERING)
plan_repo.create(plan)
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 400
def test_generate_wrong_status_completed(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
) -> None:
"""completed 状态 → 400"""
plan = _make_plan(status=EditPlanStatus.COMPLETED)
plan_repo.create(plan)
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 400
def test_generate_no_clips(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
) -> None:
"""editing 状态但没有片段 → 400"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 400
assert "片段" in resp.json()["detail"]
def test_generate_with_ready_clips(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""已有 ready 状态的片段也可以触发生成"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 200
data = resp.json()
assert data["plan_status"] == "rendering"
def test_generate_multiple_clips(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""多个片段全部从 pending 转为 ready"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
for i in range(3):
clip = _make_clip(plan.id, order=i + 1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 200
data = resp.json()
assert data["clip_count"] == 3
# 验证所有片段都变为 ready
clips = clip_repo.list_by_plan(plan.id)
assert all(c.status == EditPlanClipStatus.READY for c in clips)
# ── GET /api/v1/edit-plans/{id}/generation-status ─────────────────────────────
class TestGetGenerationStatus:
def test_status_not_found(self, client: TestClient) -> None:
"""计划不存在 → 404"""
resp = client.get("/api/v1/edit-plans/nonexistent/generation-status")
assert resp.status_code == 404
def test_status_draft_plan(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""draft 状态计划的生成状态"""
plan = _make_plan(status=EditPlanStatus.DRAFT)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
assert data["plan_id"] == plan.id
assert data["plan_status"] == "draft"
assert data["generation_task_id"] is None
assert len(data["clips"]) == 1
assert data["clips"][0]["status"] == "pending"
def test_status_rendering_plan(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""rendering 状态计划的生成状态"""
plan = _make_plan(status=EditPlanStatus.RENDERING)
plan.config["generation_task_id"] = "gen-task-001"
plan_repo.create(plan)
clip1 = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
clip2 = _make_clip(plan.id, order=2, status=EditPlanClipStatus.READY)
clip_repo.create(clip1)
clip_repo.create(clip2)
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
assert data["plan_status"] == "rendering"
assert data["generation_task_id"] == "gen-task-001"
assert len(data["clips"]) == 2
statuses = {c["status"] for c in data["clips"]}
assert "rendered" in statuses
assert "ready" in statuses
def test_status_completed_plan(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""completed 状态计划的生成状态"""
plan = _make_plan(status=EditPlanStatus.COMPLETED)
plan.config["generation_task_id"] = "gen-task-002"
plan.config["rendered_url"] = "https://oss.example.com/rendered/output.mp4"
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
clip_repo.create(clip)
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
assert data["plan_status"] == "completed"
assert data["generation_task_id"] == "gen-task-002"
assert len(data["clips"]) == 1
assert data["clips"][0]["status"] == "rendered"
def test_status_clip_fields(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""验证片段状态返回的字段完整性"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(
plan.id,
clip_type="INTRO",
order=1,
asset_id="assets/intro.mp4",
duration=5.0,
)
clip.text_content = "欢迎观看"
clip_repo.create(clip)
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
clip_data = data["clips"][0]
assert clip_data["clip_id"] == clip.id
assert clip_data["clip_type"] == "INTRO"
assert clip_data["order"] == 1
assert clip_data["asset_id"] == "assets/intro.mp4"
assert clip_data["text_content"] == "欢迎观看"
assert clip_data["duration"] == 5.0
def test_status_no_clips(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
) -> None:
"""没有片段的计划也能查询状态"""
plan = _make_plan(status=EditPlanStatus.DRAFT)
plan_repo.create(plan)
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
assert data["clips"] == []
# ── Response Schema 验证 ──────────────────────────────────────────────────────
class TestResponseSchema:
def test_generate_response_structure(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""验证 generate 端点响应结构"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 200
data = resp.json()
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clip_count"}
assert set(data.keys()) == expected_keys
def test_generation_status_response_structure(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
) -> None:
"""验证 generation-status 端点响应结构"""
plan = _make_plan(status=EditPlanStatus.DRAFT)
plan_repo.create(plan)
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
assert set(data.keys()) == expected_keys
# ── P0-1: 生成接口错误处理 ────────────────────────────────────────────────────
class TestGeneratePlanErrorHandling:
"""P0-1: generate 端点异常时返回明确错误信息,不裸 500"""
def test_generate_internal_error_returns_clear_message(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""核心流程抛异常 → 500 + 用户友好的错误信息(不暴露技术细节)"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
# 模拟 Celery 调度失败
mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时")
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 500
data = resp.json()
# 验证返回了用户友好的错误信息,不暴露技术细节
assert "生成失败" in data["detail"]
assert "RuntimeError" not in data["detail"]
assert "Redis" not in data["detail"]
def test_generate_error_rolls_back_plan_status(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""异常时将计划标记为 failedRENDERING → FAILED 是合法流转)"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
mock_celery.send_task.side_effect = RuntimeError("调度失败")
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 500
# 计划状态应变为 failed
updated = plan_repo.get(plan.id)
assert updated.status == EditPlanStatus.FAILED
def test_generate_error_detail_is_user_friendly(
self,
client: TestClient,
plan_repo: StubEditPlanRepository,
clip_repo: StubEditPlanClipRepository,
) -> None:
"""错误信息对用户友好,不暴露技术细节(异常类型、内部错误信息)"""
plan = _make_plan(status=EditPlanStatus.EDITING)
plan_repo.create(plan)
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
mock_celery.send_task.side_effect = ConnectionError("Broker 不可达")
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
assert resp.status_code == 500
detail = resp.json()["detail"]
# 验证不暴露技术细节
assert "ConnectionError" not in detail
assert "Broker 不可达" not in detail
# 验证返回了用户友好的提示
assert "生成失败" in detail