Files
xiaoxia-saas/tests/unit/test_generation_preview.py
T
CI Bot 09b8a9b52b
CI/CD Pipeline / Check if frontend-only change (push) 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 Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m21s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
CI/CD Pipeline / Unit Tests (push) Successful in 8m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 Staging API Image (push) Successful in 12m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m58s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-08-01 17:08:38 +08:00

1064 lines
39 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.
"""预览生成(Phase 1)单元测试.
覆盖:
- 领域模型 is_preview 字段
- CreateGenerationTaskCommand is_preview 字段
- UseCase 传递 is_preview
- Schema 校验(CreatePreviewGenerationTaskRequest / PreviewGenerationTaskResponse
- 仓储层 _to_domain 兼容旧数据(getattr + 默认值)
- 仓储层 create/update 保留 is_preview 字段
- 状态流转验证
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# 设置必要环境变量(必须在导入 app 模块之前)
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ── 领域模型测试 ──────────────────────────────────────────────────────────────
class TestGenerationTaskIsPreviewField:
"""GenerationTask 领域模型 is_preview 字段测试"""
def test_is_preview_default_false(self):
"""默认 is_preview=False"""
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
)
assert task.is_preview is False
def test_is_preview_true_when_specified(self):
"""显式指定 is_preview=True"""
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
is_preview=True,
)
assert task.is_preview is True
def test_is_preview_false_when_explicit_false(self):
"""显式指定 is_preview=False"""
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
is_preview=False,
)
assert task.is_preview is False
def test_is_preview_with_template_mode(self):
"""模板模式下 is_preview 正常工作"""
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tmpl1",
asset_ids=["a1", "a2"],
is_preview=True,
)
assert task.is_preview is True
assert task.template_id == "tmpl1"
assert task.asset_ids == ["a1", "a2"]
def test_is_preview_preserved_in_dataclass(self):
"""is_preview 是 dataclass 字段,可以被赋值"""
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
)
task.is_preview = True
assert task.is_preview is True
# ── UseCase 测试 ────────────────────────────────────────────────────────────
class TestCreateGenerationTaskUseCaseIsPreview:
"""CreateGenerationTaskUseCase 中 is_preview 传递测试"""
def test_command_default_is_preview_false(self):
"""CreateGenerationTaskCommand 默认 is_preview=False"""
cmd = CreateGenerationTaskCommand(
project_id="proj1",
asset_library_id="lib1",
)
assert cmd.is_preview is False
def test_command_is_preview_true(self):
"""CreateGenerationTaskCommand 设置 is_preview=True"""
cmd = CreateGenerationTaskCommand(
project_id="proj1",
asset_library_id="lib1",
is_preview=True,
)
assert cmd.is_preview is True
def test_use_case_passes_is_preview_to_task(self):
"""UseCase 将 is_preview 传递给 GenerationTask"""
mock_repo = MagicMock()
mock_repo.create.side_effect = lambda t: t
use_case = CreateGenerationTaskUseCase(mock_repo)
command = CreateGenerationTaskCommand(
project_id="proj1",
asset_library_id="lib1",
is_preview=True,
)
result = use_case.execute(command)
assert result.is_preview is True
mock_repo.create.assert_called_once()
def test_use_case_default_is_preview_false(self):
"""UseCase 默认 is_preview=False"""
mock_repo = MagicMock()
mock_repo.create.side_effect = lambda t: t
use_case = CreateGenerationTaskUseCase(mock_repo)
command = CreateGenerationTaskCommand(
project_id="proj1",
asset_library_id="lib1",
)
result = use_case.execute(command)
assert result.is_preview is False
# ── Schema 测试 ──────────────────────────────────────────────────────────────
class TestCreatePreviewGenerationTaskRequest:
"""CreatePreviewGenerationTaskRequest schema 校验测试"""
def test_valid_request_with_asset_ids(self):
"""有效请求:template_id + asset_ids"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
req = CreatePreviewGenerationTaskRequest(
template_id="tmpl_123",
asset_ids=["asset_1", "asset_2"],
)
assert req.template_id == "tmpl_123"
assert req.asset_ids == ["asset_1", "asset_2"]
assert req.title_ids == []
assert req.voice_ids == []
assert req.video_title == ""
assert req.duration == 0.0
assert req.bgm_config == {}
def test_valid_request_with_title_ids_only(self):
"""有效请求:template_id + title_ids(替代 asset_ids"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
req = CreatePreviewGenerationTaskRequest(
template_id="tmpl_123",
title_ids=["title_1"],
)
assert req.template_id == "tmpl_123"
assert req.title_ids == ["title_1"]
def test_valid_request_with_voice_ids_only(self):
"""有效请求:template_id + voice_ids"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
req = CreatePreviewGenerationTaskRequest(
template_id="tmpl_123",
voice_ids=["voice_1"],
)
assert req.voice_ids == ["voice_1"]
def test_missing_template_id_raises(self):
"""缺少 template_id 报错"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
from pydantic import ValidationError
with pytest.raises(ValidationError, match="template_id"):
CreatePreviewGenerationTaskRequest(
template_id="",
asset_ids=["asset_1"],
)
def test_missing_template_id_not_provided_raises(self):
"""完全不提供 template_id 报错"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
from pydantic import ValidationError
with pytest.raises(ValidationError):
CreatePreviewGenerationTaskRequest(
asset_ids=["asset_1"],
)
def test_empty_asset_ids_raises(self):
"""asset_ids/title_ids/voice_ids 全空报错"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
from pydantic import ValidationError
with pytest.raises(ValidationError, match="asset_ids"):
CreatePreviewGenerationTaskRequest(
template_id="tmpl_123",
asset_ids=[],
title_ids=[],
voice_ids=[],
)
def test_request_with_all_fields(self):
"""所有字段都设置的请求"""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
req = CreatePreviewGenerationTaskRequest(
template_id="tmpl_123",
asset_ids=["a1", "a2"],
title_ids=["t1"],
voice_ids=["v1"],
video_title="测试预览视频",
duration=30.0,
video_ratio="9:16",
bgm_config={"enabled": True, "volume": 0.5},
)
assert req.video_title == "测试预览视频"
assert req.duration == 30.0
assert req.video_ratio == "9:16"
assert req.bgm_config["enabled"] is True
assert req.bgm_config["volume"] == 0.5
class TestPreviewGenerationTaskResponse:
"""PreviewGenerationTaskResponse schema 测试"""
def test_pending_state_response(self):
"""pending 状态的响应"""
resp = PreviewGenerationTaskResponse(
task_id="task_123",
status="pending",
progress=0.0,
)
assert resp.task_id == "task_123"
assert resp.status == "pending"
assert resp.progress == 0.0
assert resp.is_preview is True
assert resp.resolution == ""
assert resp.video_url == ""
assert resp.duration == 0.0
assert resp.file_size == 0
assert resp.clip_count == 0
assert resp.error_message == ""
def test_completed_state_response(self):
"""completed 状态的响应"""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
resp = PreviewGenerationTaskResponse(
task_id="task_123",
status="completed",
progress=100.0,
is_preview=True,
resolution="854x480",
video_url="https://example.com/preview.mp4",
duration=30.5,
file_size=5_000_000,
clip_count=5,
transition_count=4,
material_usage={"videos": 5, "images": 2},
created_at=now,
started_at=now,
finished_at=now,
generate_duration=12.5,
)
assert resp.status == "completed"
assert resp.progress == 100.0
assert resp.resolution == "854x480"
assert resp.video_url == "https://example.com/preview.mp4"
assert resp.duration == 30.5
assert resp.file_size == 5_000_000
assert resp.clip_count == 5
assert resp.transition_count == 4
assert resp.generate_duration == 12.5
def test_failed_state_response(self):
"""failed 状态的响应"""
resp = PreviewGenerationTaskResponse(
task_id="task_123",
status="failed",
progress=30.0,
error_message="渲染失败:素材格式不支持",
)
assert resp.status == "failed"
assert resp.error_message == "渲染失败:素材格式不支持"
assert resp.video_url == ""
# ── 仓储层兼容测试 ──────────────────────────────────────────────────────────
class TestRepositoryIsPreviewCompatibility:
"""仓储层 is_preview 向后兼容测试"""
def test_to_domain_with_is_preview_true(self):
"""新数据 is_preview=True 时正确映射"""
mock_model = MagicMock()
mock_model.id = "task_123"
mock_model.project_id = "proj1"
mock_model.strategy_id = ""
mock_model.asset_library_id = "lib1"
mock_model.voice_library_id = ""
mock_model.template_id = "tmpl1"
mock_model.asset_ids = ["a1"]
mock_model.title_ids = []
mock_model.voice_ids = []
mock_model.status = "pending"
mock_model.progress = 0.0
mock_model.result_count = 0
mock_model.error_message = ""
mock_model.error_info = {}
mock_model.retry_count = 0
mock_model.auto_retry_enabled = False
mock_model.auto_retry_max = 0
mock_model.started_at = None
mock_model.completed_at = None
mock_model.created_by_user_id = "user1"
mock_model.source_edit_plan_id = None
mock_model.asset_select_mode = ""
mock_model.batch_id = ""
mock_model.video_title = ""
mock_model.resolution = "854x480"
mock_model.bgm_config = {}
mock_model.is_preview = True
mock_model.logs = "[]"
mock_model.created_at = datetime.now(timezone.utc)
mock_model.updated_at = datetime.now(timezone.utc)
from packages.adapters.sqlalchemy_impl.generation_task_repository import _to_domain
result = _to_domain(mock_model)
assert result.is_preview is True
assert result.resolution == "854x480"
def test_to_domain_with_is_preview_false(self):
"""is_preview=False 时正确映射"""
mock_model = MagicMock()
mock_model.id = "task_123"
mock_model.project_id = "proj1"
mock_model.strategy_id = ""
mock_model.asset_library_id = "lib1"
mock_model.voice_library_id = ""
mock_model.template_id = ""
mock_model.asset_ids = []
mock_model.title_ids = []
mock_model.voice_ids = []
mock_model.status = "pending"
mock_model.progress = 0.0
mock_model.result_count = 0
mock_model.error_message = ""
mock_model.error_info = {}
mock_model.retry_count = 0
mock_model.auto_retry_enabled = False
mock_model.auto_retry_max = 0
mock_model.started_at = None
mock_model.completed_at = None
mock_model.created_by_user_id = "user1"
mock_model.source_edit_plan_id = None
mock_model.asset_select_mode = ""
mock_model.batch_id = ""
mock_model.video_title = ""
mock_model.resolution = ""
mock_model.bgm_config = {}
mock_model.is_preview = False
mock_model.logs = "[]"
mock_model.created_at = datetime.now(timezone.utc)
mock_model.updated_at = datetime.now(timezone.utc)
from packages.adapters.sqlalchemy_impl.generation_task_repository import _to_domain
result = _to_domain(mock_model)
assert result.is_preview is False
def test_repository_create_includes_is_preview(self):
"""repository.create() 包含 is_preview 字段"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base, GenerationTaskModel
# 使用内存数据库
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
try:
repo = SQLAlchemyGenerationTaskRepository(session)
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
is_preview=True,
resolution="854x480",
)
repo.create(task)
# 直接查 model 验证
model = session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first()
assert model is not None
assert model.is_preview is True
assert model.resolution == "854x480"
finally:
session.close()
def test_repository_update_preserves_is_preview(self):
"""repository.update() 保留 is_preview 字段"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base, GenerationTaskModel
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
try:
repo = SQLAlchemyGenerationTaskRepository(session)
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
is_preview=True,
)
repo.create(task)
# 更新任务状态
task.mark_processing()
repo.update(task)
model = session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first()
assert model is not None
assert model.is_preview is True # is_preview 应该保持不变
assert model.status == "running"
finally:
session.close()
# ── 状态流转测试 ────────────────────────────────────────────────────────────
class TestPreviewTaskStatusFlow:
"""预览任务状态流转测试"""
def test_pending_to_running(self):
"""pending → running"""
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tmpl1",
asset_ids=["a1"],
is_preview=True,
)
assert task.status == GenerationTaskStatus.PENDING
assert task.is_preview is True
task.mark_processing()
assert task.status == GenerationTaskStatus.RUNNING
assert task.started_at is not None
def test_running_to_completed(self):
"""running → completed"""
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tmpl1",
asset_ids=["a1"],
is_preview=True,
)
task.mark_processing()
task.mark_completed()
assert task.status == GenerationTaskStatus.COMPLETED
assert task.progress == 100.0
assert task.completed_at is not None
assert task.is_preview is True
def test_running_to_failed(self):
"""running → failed"""
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tmpl1",
asset_ids=["a1"],
is_preview=True,
)
task.mark_processing()
task.mark_failed("渲染错误")
assert task.status == GenerationTaskStatus.FAILED
assert task.error_message == "渲染错误"
assert task.is_preview is True
def test_pending_to_cancelled(self):
"""pending → cancelled"""
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tmpl1",
asset_ids=["a1"],
is_preview=True,
)
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.is_preview is True
def test_preview_resolution_is_480p(self):
"""预览任务分辨率为 854x480"""
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tmpl1",
asset_ids=["a1"],
resolution="854x480",
is_preview=True,
)
assert task.resolution == "854x480"
assert task.is_preview is True
def test_non_preview_task_default_false(self):
"""非预览任务 is_preview 默认 False"""
task = GenerationTask.create(
project_id="proj1",
asset_library_id="lib1",
)
assert task.is_preview is False
# ═══════════════════════════════════════════════════════════════════════════════
# 路由层测试 — 覆盖 generation_preview.py 的 HTTP handler 逻辑
# ═══════════════════════════════════════════════════════════════════════════════
from dataclasses import dataclass
from unittest.mock import PropertyMock, patch
# 导入路由模块和被测函数
from app.api.routes.generation_preview import (
PREVIEW_RESOLUTION,
_to_preview_response,
create_preview_generation_task,
get_preview_generation_task,
)
from app.core.task_enqueue import GlobalQueueFull, UserPendingLimitExceeded
from app.schemas.generation_task import (
CreatePreviewGenerationTaskRequest,
PreviewGenerationTaskResponse,
)
def _make_user(user_id="test_user_001"):
"""构造 mock AuthenticatedUser"""
mock_user = MagicMock()
mock_user.id = user_id
auth = MagicMock()
auth.user = mock_user
auth.session_id = "sess_001"
auth.token_type = "bearer"
return auth
def _make_task(
task_id="preview_task_001",
status=GenerationTaskStatus.PENDING,
progress=0.0,
is_preview=True,
resolution="854x480",
error_message="",
created_at=None,
started_at=None,
completed_at=None,
extra_meta=None,
asset_ids=None,
):
"""构造 mock GenerationTask"""
task = MagicMock()
task.id = task_id
task.status = status
task.progress = progress
task.is_preview = is_preview
task.resolution = resolution
task.error_message = error_message
task.created_at = created_at or datetime.now(timezone.utc)
task.started_at = started_at
task.completed_at = completed_at
task.extra_meta = extra_meta or {}
task.asset_ids = asset_ids or []
task.created_by_user_id = "test_user_001"
return task
class TestToPreviewResponse:
"""_to_preview_response 辅助函数测试"""
def test_basic_pending_task(self):
"""基本 pending 任务转换"""
task = _make_task(status=GenerationTaskStatus.PENDING)
resp = _to_preview_response(task)
assert resp.task_id == "preview_task_001"
assert resp.status == "pending"
assert resp.is_preview is True
assert resp.video_url == ""
assert resp.duration == 0.0
assert resp.file_size == 0
def test_completed_task_with_videos(self):
"""已完成任务,带视频结果"""
task = _make_task(
status=GenerationTaskStatus.COMPLETED,
progress=100.0,
)
video = MagicMock()
video.file_url = "https://cdn.example.com/preview.mp4"
video.duration = 30.5
video.file_size = 1024000
resp = _to_preview_response(task, generated_videos=[video])
assert resp.video_url == "https://cdn.example.com/preview.mp4"
assert resp.duration == 30.5
assert resp.file_size == 1024000
def test_extra_meta_fields(self):
"""从 extra_meta 提取统计信息"""
task = _make_task(
extra_meta={
"clip_count": 5,
"transition_count": 4,
"material_usage": {"video": 3, "audio": 2},
}
)
resp = _to_preview_response(task)
assert resp.clip_count == 5
assert resp.transition_count == 4
assert resp.material_usage == {"video": 3, "audio": 2}
def test_generate_duration_calculation(self):
"""计算生成耗时"""
from datetime import timedelta
started = datetime(2026, 8, 1, 10, 0, 0, tzinfo=timezone.utc)
completed = started + timedelta(seconds=45)
task = _make_task(started_at=started, completed_at=completed)
resp = _to_preview_response(task)
assert resp.generate_duration == 45.0
def test_no_started_completed_time(self):
"""无开始/完成时间时 generate_duration=0"""
task = _make_task(started_at=None, completed_at=None)
resp = _to_preview_response(task)
assert resp.generate_duration == 0.0
def test_empty_generated_videos(self):
"""空视频列表时 video_url 为空"""
task = _make_task()
resp = _to_preview_response(task, generated_videos=[])
assert resp.video_url == ""
def test_none_generated_videos(self):
"""None 视频列表时 video_url 为空"""
task = _make_task()
resp = _to_preview_response(task, generated_videos=None)
assert resp.video_url == ""
class TestCreatePreviewRoute:
"""POST /preview 路由测试"""
def _make_request(self, **kwargs):
defaults = {
"template_id": "tpl_001",
"asset_ids": ["asset_1", "asset_2"],
"title_ids": [],
"voice_ids": [],
"video_title": "测试预览",
"duration": 0.0,
"video_ratio": "",
"bgm_config": {},
}
defaults.update(kwargs)
return CreatePreviewGenerationTaskRequest(**defaults)
def test_create_success(self):
"""正常创建预览任务"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 5
task = _make_task()
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch(
"app.api.routes.generation_preview.safe_enqueue_generation_task",
return_value=True,
):
resp = create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert resp.task_id == "preview_task_001"
assert resp.status == "pending"
def test_user_pending_limit_exceeded(self):
"""用户待处理任务超限 → 429"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 3
repo.count_pending_total.return_value = 5
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 429
def test_global_queue_full(self):
"""全局队列满 → 503"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 20
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 503
def test_create_value_error(self):
"""UseCase 抛出 ValueError → 400"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.side_effect = ValueError("template_id 不能为空")
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 400
def test_create_unexpected_error(self):
"""UseCase 抛出未知异常 → 500"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.side_effect = RuntimeError("db connection lost")
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 500
def test_enqueue_returns_false(self):
"""safe_enqueue 返回 False → 500"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
task = _make_task()
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch(
"app.api.routes.generation_preview.safe_enqueue_generation_task",
return_value=False,
):
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 500
def test_enqueue_raises_user_limit(self):
"""safe_enqueue 抛出 UserPendingLimitExceeded → 429"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
task = _make_task()
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch(
"app.api.routes.generation_preview.safe_enqueue_generation_task",
side_effect=UserPendingLimitExceeded(user_id="u1", pending_count=4, limit=3),
):
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 429
def test_enqueue_raises_global_queue_full(self):
"""safe_enqueue 抛出 GlobalQueueFull → 503"""
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
task = _make_task()
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch(
"app.api.routes.generation_preview.safe_enqueue_generation_task",
side_effect=GlobalQueueFull(pending_count=21, limit=20),
):
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
)
assert exc_info.value.status_code == 503
class TestGetPreviewRoute:
"""GET /preview/{task_id} 路由测试"""
def test_task_not_found(self):
"""任务不存在 → 404"""
repo = MagicMock()
vid_repo = MagicMock()
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = None
with pytest.raises(HTTPException) as exc_info:
get_preview_generation_task(
task_id="nonexistent",
authenticated_user=_make_user(),
generation_task_repository=repo,
generated_video_repository=vid_repo,
)
assert exc_info.value.status_code == 404
def test_permission_denied(self):
"""任务不属于当前用户 → 403"""
repo = MagicMock()
vid_repo = MagicMock()
task = _make_task()
task.created_by_user_id = "other_user_999"
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with pytest.raises(HTTPException) as exc_info:
get_preview_generation_task(
task_id="preview_task_001",
authenticated_user=_make_user("test_user_001"),
generation_task_repository=repo,
generated_video_repository=vid_repo,
)
assert exc_info.value.status_code == 403
def test_not_preview_task(self):
"""查询的不是预览任务 → 404"""
repo = MagicMock()
vid_repo = MagicMock()
task = _make_task(is_preview=False)
task.created_by_user_id = "test_user_001"
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with pytest.raises(HTTPException) as exc_info:
get_preview_generation_task(
task_id="preview_task_001",
authenticated_user=_make_user(),
generation_task_repository=repo,
generated_video_repository=vid_repo,
)
assert exc_info.value.status_code == 404
def test_get_pending_task(self):
"""查询 pending 状态的预览任务"""
repo = MagicMock()
vid_repo = MagicMock()
task = _make_task(status=GenerationTaskStatus.PENDING, progress=0.0)
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
resp = get_preview_generation_task(
task_id="preview_task_001",
authenticated_user=_make_user(),
generation_task_repository=repo,
generated_video_repository=vid_repo,
)
assert resp.status == "pending"
assert resp.is_preview is True
def test_empty_user_id_denied(self):
"""任务 created_by_user_id 为空 → 403(防止越权)"""
repo = MagicMock()
vid_repo = MagicMock()
task = _make_task()
task.created_by_user_id = "" # 空字符串
from fastapi import HTTPException
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with pytest.raises(HTTPException) as exc_info:
get_preview_generation_task(
task_id="preview_task_001",
authenticated_user=_make_user(),
generation_task_repository=repo,
generated_video_repository=vid_repo,
)
assert exc_info.value.status_code == 403
def test_get_completed_task_with_videos(self):
"""查询 completed 状态任务,返回视频列表"""
repo = MagicMock()
vid_repo = MagicMock()
task = _make_task(status=GenerationTaskStatus.COMPLETED, progress=100.0)
video = MagicMock()
video.file_url = "https://cdn.example.com/preview_final.mp4"
video.duration = 25.0
video.file_size = 512000
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockGet:
MockGet.return_value.execute.return_value = task
with patch("app.api.routes.generation_preview.ListGeneratedVideosByTaskUseCase") as MockList:
MockList.return_value.execute.return_value = [video]
resp = get_preview_generation_task(
task_id="preview_task_001",
authenticated_user=_make_user(),
generation_task_repository=repo,
generated_video_repository=vid_repo,
)
assert resp.video_url == "https://cdn.example.com/preview_final.mp4"
assert resp.duration == 25.0
# ═══════════════════════════════════════════════════════════════════════════════
# Worker 预览模式测试 — 覆盖 generation.py 预览分辨率强制逻辑
# ═══════════════════════════════════════════════════════════════════════════════
class TestWorkerPreviewResolution:
"""Worker 层预览模式分辨率强制逻辑测试"""
def test_preview_mode_forces_480p(self):
"""预览模式强制 854x480 + 1M 码率"""
# 模拟 worker 中 export_cfg 逻辑(与实际代码一致,使用 dict 拷贝)
is_preview = True
resolution = "1920x1080" # 用户指定的分辨率应被忽略
original_config = {"export": {"resolution": "1280x720", "bitrate": "5M"}}
plan_cfg = dict(original_config)
export_cfg = dict(plan_cfg.get("export", {}) or {})
if is_preview:
export_cfg["resolution"] = "854x480"
export_cfg["bitrate"] = "1M"
elif resolution:
export_cfg["resolution"] = resolution
plan_cfg["export"] = export_cfg
assert export_cfg["resolution"] == "854x480"
assert export_cfg["bitrate"] == "1M"
# 验证原始配置未被污染
assert original_config["export"]["resolution"] == "1280x720"
def test_non_preview_uses_user_resolution(self):
"""非预览模式使用用户指定分辨率"""
is_preview = False
resolution = "1920x1080"
plan_cfg = dict({"export": {"resolution": "1280x720"}})
export_cfg = dict(plan_cfg.get("export", {}) or {})
if is_preview:
export_cfg["resolution"] = "854x480"
export_cfg["bitrate"] = "1M"
elif resolution:
export_cfg["resolution"] = resolution
assert export_cfg["resolution"] == "1920x1080"
def test_non_preview_no_resolution_uses_template(self):
"""非预览模式且用户未指定分辨率,使用模板配置"""
is_preview = False
resolution = ""
plan_cfg = dict({"export": {"resolution": "1280x720"}})
export_cfg = dict(plan_cfg.get("export", {}) or {})
if is_preview:
export_cfg["resolution"] = "854x480"
export_cfg["bitrate"] = "1M"
elif resolution:
export_cfg["resolution"] = resolution
elif not export_cfg.get("resolution"):
export_cfg["resolution"] = "1280x720"
assert export_cfg["resolution"] == "1280x720"
def test_preview_mode_empty_export_cfg(self):
"""预览模式且模板无 export 配置"""
is_preview = True
plan_cfg = dict({})
export_cfg = dict(plan_cfg.get("export", {}) or {})
if is_preview:
export_cfg["resolution"] = "854x480"
export_cfg["bitrate"] = "1M"
assert export_cfg["resolution"] == "854x480"
assert export_cfg["bitrate"] == "1M"