004de97394
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m50s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m3s
CI/CD Pipeline / Unit Tests (push) Successful in 8m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
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 / Build Staging API Image (push) Successful in 11m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m49s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 17s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m9s
1507 lines
59 KiB
Python
1507 lines
59 KiB
Python
"""预览生成(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):
|
||
"""已完成任务,带视频结果(URL签名后返回)"""
|
||
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
|
||
# Mock storage service to return a signed URL
|
||
mock_storage = MagicMock()
|
||
mock_storage.get_download_url.return_value = "https://cdn.example.com/preview.mp4?sig=test123"
|
||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||
resp = _to_preview_response(task, generated_videos=[video])
|
||
assert resp.video_url == "https://cdn.example.com/preview.mp4?sig=test123"
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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,
|
||
db=MagicMock(),
|
||
)
|
||
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 状态任务,返回视频列表(URL 已签名)"""
|
||
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
|
||
|
||
# Mock URL 签名(返回带签名的 URL)
|
||
mock_storage = MagicMock()
|
||
mock_storage.get_download_url.return_value = "https://cdn.example.com/preview_final.mp4?sig=abc123"
|
||
|
||
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]
|
||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||
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?sig=abc123"
|
||
assert resp.duration == 25.0
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Worker 预览模式测试 — 覆盖 generation.py 预览分辨率强制逻辑
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestWorkerPreviewResolution:
|
||
"""Worker 层预览模式分辨率强制逻辑测试"""
|
||
|
||
def test_preview_mode_respects_resolution(self):
|
||
"""预览模式尊重传入的 resolution 参数(如竖屏 480x854)"""
|
||
is_preview = True
|
||
resolution = "480x854" # 竖屏模板
|
||
original_config = {"export": {"resolution": "1280x720", "bitrate": "5M"}}
|
||
plan_cfg = dict(original_config)
|
||
export_cfg = dict(plan_cfg.get("export", {}) or {})
|
||
|
||
if is_preview:
|
||
preview_res = resolution if resolution else "854x480"
|
||
export_cfg["resolution"] = preview_res
|
||
export_cfg["bitrate"] = "1M"
|
||
elif resolution:
|
||
export_cfg["resolution"] = resolution
|
||
|
||
plan_cfg["export"] = export_cfg
|
||
|
||
assert export_cfg["resolution"] == "480x854" # 竖屏
|
||
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:
|
||
preview_res = resolution if resolution else "854x480"
|
||
export_cfg["resolution"] = preview_res
|
||
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:
|
||
preview_res = resolution if resolution else "854x480"
|
||
export_cfg["resolution"] = preview_res
|
||
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 配置,无传入 resolution 时默认 854x480"""
|
||
is_preview = True
|
||
resolution = ""
|
||
plan_cfg = dict({})
|
||
export_cfg = dict(plan_cfg.get("export", {}) or {})
|
||
|
||
if is_preview:
|
||
preview_res = resolution if resolution else "854x480"
|
||
export_cfg["resolution"] = preview_res
|
||
export_cfg["bitrate"] = "1M"
|
||
|
||
assert export_cfg["resolution"] == "854x480"
|
||
assert export_cfg["bitrate"] == "1M"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 新增:URL 签名 + 模板时长约束测试
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestSignVideoUrl:
|
||
"""_sign_video_url 预签名 URL 测试。"""
|
||
|
||
def test_empty_url_returns_empty(self):
|
||
"""空 URL 直接返回空字符串。"""
|
||
from app.api.routes.generation_preview import _sign_video_url
|
||
|
||
assert _sign_video_url("") == ""
|
||
|
||
def test_signs_oss_url(self):
|
||
"""OSS URL 应被签名。"""
|
||
from app.api.routes.generation_preview import _sign_video_url
|
||
|
||
mock_storage = MagicMock()
|
||
mock_storage.get_download_url.return_value = "https://signed.example.com/video.mp4?sig=abc"
|
||
|
||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||
result = _sign_video_url("https://bucket.oss.example.com/video.mp4")
|
||
|
||
assert result == "https://signed.example.com/video.mp4?sig=abc"
|
||
mock_storage.get_download_url.assert_called_once()
|
||
|
||
def test_fallback_on_sign_failure(self):
|
||
"""签名失败时降级返回原始 URL。"""
|
||
from app.api.routes.generation_preview import _sign_video_url
|
||
|
||
with patch("app.api.routes.generation_preview.get_storage_service", side_effect=RuntimeError("no storage")):
|
||
result = _sign_video_url("https://bucket.oss.example.com/video.mp4")
|
||
|
||
assert result == "https://bucket.oss.example.com/video.mp4"
|
||
|
||
def test_sign_returns_none_fallback(self):
|
||
"""get_download_url 返回 None 时降级返回原始 URL。"""
|
||
from app.api.routes.generation_preview import _sign_video_url
|
||
|
||
mock_storage = MagicMock()
|
||
mock_storage.get_download_url.return_value = None
|
||
|
||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||
result = _sign_video_url("https://bucket.oss.example.com/video.mp4")
|
||
|
||
assert result == "https://bucket.oss.example.com/video.mp4"
|
||
|
||
|
||
class TestCalcPreviewResolution:
|
||
"""_calc_preview_resolution 单元测试。"""
|
||
|
||
def test_portrait_9_16(self):
|
||
from app.api.routes.generation_preview import _calc_preview_resolution
|
||
|
||
assert _calc_preview_resolution("9:16") == "480x854"
|
||
|
||
def test_landscape_16_9(self):
|
||
from app.api.routes.generation_preview import _calc_preview_resolution
|
||
|
||
assert _calc_preview_resolution("16:9") == "854x480"
|
||
|
||
def test_square_1_1(self):
|
||
from app.api.routes.generation_preview import _calc_preview_resolution
|
||
|
||
assert _calc_preview_resolution("1:1") == "480x480"
|
||
|
||
def test_unknown_defaults_to_landscape(self):
|
||
from app.api.routes.generation_preview import _calc_preview_resolution
|
||
|
||
assert _calc_preview_resolution("unknown") == "854x480"
|
||
|
||
def test_empty_defaults_to_landscape(self):
|
||
from app.api.routes.generation_preview import _calc_preview_resolution
|
||
|
||
assert _calc_preview_resolution("") == "854x480"
|
||
|
||
|
||
class TestInferVideoRatioFromTemplate:
|
||
"""_infer_video_ratio_from_template 单元测试。"""
|
||
|
||
def test_pip_mode_returns_9_16(self):
|
||
"""模板 mode=pip → 返回 '9:16'"""
|
||
from app.api.routes.generation_preview import _infer_video_ratio_from_template
|
||
|
||
mock_template = MagicMock()
|
||
mock_template.mode = "pip"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockRepo:
|
||
MockRepo.return_value.get.return_value = mock_template
|
||
result = _infer_video_ratio_from_template("tpl_001", mock_db, "user_1")
|
||
assert result == "9:16"
|
||
|
||
def test_standard_mode_returns_16_9(self):
|
||
"""模板 mode=standard → 返回 '16:9'"""
|
||
from app.api.routes.generation_preview import _infer_video_ratio_from_template
|
||
|
||
mock_template = MagicMock()
|
||
mock_template.mode = "standard"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockRepo:
|
||
MockRepo.return_value.get.return_value = mock_template
|
||
result = _infer_video_ratio_from_template("tpl_001", mock_db, "user_1")
|
||
assert result == "16:9"
|
||
|
||
def test_unknown_mode_returns_empty(self):
|
||
"""模板 mode 未知 → 返回空字符串"""
|
||
from app.api.routes.generation_preview import _infer_video_ratio_from_template
|
||
|
||
mock_template = MagicMock()
|
||
mock_template.mode = "unknown_mode"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockRepo:
|
||
MockRepo.return_value.get.return_value = mock_template
|
||
result = _infer_video_ratio_from_template("tpl_001", mock_db, "user_1")
|
||
assert result == ""
|
||
|
||
def test_template_not_found_returns_empty(self):
|
||
"""模板不存在 → 返回空字符串"""
|
||
from app.api.routes.generation_preview import _infer_video_ratio_from_template
|
||
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockRepo:
|
||
MockRepo.return_value.get.return_value = None
|
||
result = _infer_video_ratio_from_template("nonexistent", mock_db, "user_1")
|
||
assert result == ""
|
||
|
||
def test_empty_template_id_returns_empty(self):
|
||
"""空 template_id → 直接返回空字符串"""
|
||
from app.api.routes.generation_preview import _infer_video_ratio_from_template
|
||
|
||
mock_db = MagicMock()
|
||
result = _infer_video_ratio_from_template("", mock_db, "user_1")
|
||
assert result == ""
|
||
|
||
def test_db_exception_returns_empty(self):
|
||
"""DB 异常 → 返回空字符串,不抛出"""
|
||
from app.api.routes.generation_preview import _infer_video_ratio_from_template
|
||
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockRepo:
|
||
MockRepo.side_effect = Exception("db connection error")
|
||
result = _infer_video_ratio_from_template("tpl_001", mock_db, "user_1")
|
||
assert result == ""
|
||
|
||
|
||
class TestPreviewRouteAutoInfersVideoRatio:
|
||
"""验证预览路由在前端未传 video_ratio 时自动从模板推断。"""
|
||
|
||
def test_auto_infer_pip_resolution(self):
|
||
"""前端传 video_ratio='',模板 mode=pip → resolution=480x854"""
|
||
from app.api.routes.generation_preview import create_preview_generation_task
|
||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||
|
||
repo = MagicMock()
|
||
repo.count_pending_by_user.return_value = 0
|
||
repo.count_pending_total.return_value = 0
|
||
mock_db = MagicMock()
|
||
|
||
mock_template = MagicMock()
|
||
mock_template.mode = "pip"
|
||
|
||
task = _make_task()
|
||
|
||
request = CreatePreviewGenerationTaskRequest(
|
||
template_id="tpl_pip",
|
||
asset_ids=["a1"],
|
||
title_ids=[],
|
||
voice_ids=[],
|
||
video_title="test",
|
||
duration=0.0,
|
||
video_ratio="",
|
||
bgm_config={},
|
||
)
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = None # 新模板系统无数据,fallback 旧系统
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockRepo:
|
||
MockRepo.return_value.get.return_value = mock_template
|
||
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(
|
||
request,
|
||
authenticated_user=_make_user(),
|
||
generation_task_repository=repo,
|
||
db=mock_db,
|
||
)
|
||
|
||
# Verify the resolution passed to CreateGenerationTaskCommand
|
||
call_args = MockUC.return_value.execute.call_args
|
||
cmd = call_args[0][0]
|
||
assert cmd.resolution == "480x854", f"Expected 480x854, got {cmd.resolution}"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# _resolve_strategy_id_from_template 单元测试
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestResolveStrategyIdFromTemplate:
|
||
"""_resolve_strategy_id_from_template 单元测试:预览生成从模板读取 editing_mode。"""
|
||
|
||
def test_new_template_found_returns_editing_mode(self):
|
||
"""新模板系统找到模板 → 返回 editing_mode。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_new_template = MagicMock()
|
||
mock_new_template.editing_mode = "standard"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = mock_new_template
|
||
result = _resolve_strategy_id_from_template("tpl_001", mock_db, "user_1")
|
||
|
||
assert result == "standard"
|
||
|
||
def test_new_template_not_found_fallback_to_old(self):
|
||
"""新模板系统未找到 → fallback 旧模板系统返回 mode。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_old_template = MagicMock()
|
||
mock_old_template.mode = "standard"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = None
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockOldRepo:
|
||
MockOldRepo.return_value.get.return_value = mock_old_template
|
||
result = _resolve_strategy_id_from_template("tpl_001", mock_db, "user_1")
|
||
|
||
assert result == "standard"
|
||
|
||
def test_both_not_found_returns_empty(self):
|
||
"""新旧模板系统都未找到 → 返回空字符串。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = None
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockOldRepo:
|
||
MockOldRepo.return_value.get.return_value = None
|
||
result = _resolve_strategy_id_from_template("tpl_nonexistent", mock_db, "user_1")
|
||
|
||
assert result == ""
|
||
|
||
def test_empty_template_id_returns_empty(self):
|
||
"""空 template_id → 直接返回空字符串。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_db = MagicMock()
|
||
result = _resolve_strategy_id_from_template("", mock_db, "user_1")
|
||
assert result == ""
|
||
|
||
def test_new_template_exception_fallback_to_old(self):
|
||
"""新模板系统异常 → 降级到旧模板系统。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_old_template = MagicMock()
|
||
mock_old_template.mode = "voice_over"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.side_effect = RuntimeError("db error")
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockOldRepo:
|
||
MockOldRepo.return_value.get.return_value = mock_old_template
|
||
result = _resolve_strategy_id_from_template("tpl_001", mock_db, "user_1")
|
||
|
||
assert result == "voice_over"
|
||
|
||
def test_both_exception_returns_empty(self):
|
||
"""新旧模板系统都异常 → 返回空字符串。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.side_effect = RuntimeError("new db error")
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockOldRepo:
|
||
MockOldRepo.side_effect = RuntimeError("old db error")
|
||
result = _resolve_strategy_id_from_template("tpl_001", mock_db, "user_1")
|
||
|
||
assert result == ""
|
||
|
||
def test_new_template_empty_editing_mode_fallback(self):
|
||
"""新模板找到但 editing_mode 为空 → fallback 旧模板。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_new_template = MagicMock()
|
||
mock_new_template.editing_mode = ""
|
||
mock_old_template = MagicMock()
|
||
mock_old_template.mode = "one_take"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = mock_new_template
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockOldRepo:
|
||
MockOldRepo.return_value.get.return_value = mock_old_template
|
||
result = _resolve_strategy_id_from_template("tpl_001", mock_db, "user_1")
|
||
|
||
assert result == "one_take"
|
||
|
||
def test_voice_pip_mode(self):
|
||
"""模板 editing_mode=voice_pip → 返回 one_take(画中画已下线,统一映射为one_take)。"""
|
||
from app.api.routes.generation_preview import _resolve_strategy_id_from_template
|
||
|
||
mock_new_template = MagicMock()
|
||
mock_new_template.editing_mode = "voice_pip"
|
||
mock_db = MagicMock()
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = mock_new_template
|
||
result = _resolve_strategy_id_from_template("tpl_voice", mock_db, "user_1")
|
||
|
||
assert result == "one_take"
|
||
|
||
|
||
class TestPreviewRoutePassesStrategyId:
|
||
"""验证预览路由正确传递 strategy_id 到 CreateGenerationTaskCommand。"""
|
||
|
||
def test_strategy_id_from_new_template(self):
|
||
"""预览路由从新模板读取 strategy_id=standard。"""
|
||
from app.api.routes.generation_preview import create_preview_generation_task
|
||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||
|
||
repo = MagicMock()
|
||
repo.count_pending_by_user.return_value = 0
|
||
repo.count_pending_total.return_value = 0
|
||
mock_db = MagicMock()
|
||
|
||
mock_new_template = MagicMock()
|
||
mock_new_template.editing_mode = "standard"
|
||
|
||
task = _make_task()
|
||
|
||
request = CreatePreviewGenerationTaskRequest(
|
||
template_id="tpl_pip",
|
||
asset_ids=["a1"],
|
||
title_ids=[],
|
||
voice_ids=[],
|
||
video_title="test",
|
||
duration=0.0,
|
||
video_ratio="",
|
||
bgm_config={},
|
||
)
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = mock_new_template
|
||
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(
|
||
request,
|
||
authenticated_user=_make_user(),
|
||
generation_task_repository=repo,
|
||
db=mock_db,
|
||
)
|
||
|
||
call_args = MockUC.return_value.execute.call_args
|
||
cmd = call_args[0][0]
|
||
assert cmd.strategy_id == "standard", f"Expected strategy_id='standard', got '{cmd.strategy_id}'"
|
||
|
||
def test_strategy_id_fallback_to_old_template(self):
|
||
"""新模板无数据时,从旧模板读取 strategy_id=standard。"""
|
||
from app.api.routes.generation_preview import create_preview_generation_task
|
||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||
|
||
repo = MagicMock()
|
||
repo.count_pending_by_user.return_value = 0
|
||
repo.count_pending_total.return_value = 0
|
||
mock_db = MagicMock()
|
||
|
||
mock_old_template = MagicMock()
|
||
mock_old_template.mode = "standard"
|
||
|
||
task = _make_task()
|
||
|
||
request = CreatePreviewGenerationTaskRequest(
|
||
template_id="tpl_standard",
|
||
asset_ids=["a1"],
|
||
title_ids=[],
|
||
voice_ids=[],
|
||
video_title="test",
|
||
duration=0.0,
|
||
video_ratio="",
|
||
bgm_config={},
|
||
)
|
||
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyEditTemplateRepository") as MockNewRepo:
|
||
MockNewRepo.return_value.get.return_value = None
|
||
with patch("app.api.routes.generation_preview.SQLAlchemyTemplateRepository") as MockOldRepo:
|
||
MockOldRepo.return_value.get.return_value = mock_old_template
|
||
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(
|
||
request,
|
||
authenticated_user=_make_user(),
|
||
generation_task_repository=repo,
|
||
db=mock_db,
|
||
)
|
||
|
||
call_args = MockUC.return_value.execute.call_args
|
||
cmd = call_args[0][0]
|
||
assert cmd.strategy_id == "standard", f"Expected strategy_id='standard', got '{cmd.strategy_id}'"
|