Files
xiaoxia-saas/tests/unit/test_generation_preview.py
T
xiaoxia f5100b7b5d
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
AI Code Review / AI Code Review (pull_request) Failing after 2m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m27s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m34s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 17m7s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m12s
CI/CD Pipeline / CI Gate (pull_request) Failing after 20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 56s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
fix: 上传的配音素材直接使用 file_url,不调 TTS 合成
- 配音预览逻辑优化:优先检查 selectedVoice 是否为已上传素材
- 如果是上传素材,直接使用其 file_url 作为预览音频
- 仅对预设音色或克隆音色调用 previewTts 接口合成
- 切换配音或标题时自动重新生成预览音频

解决用户反馈:Step3 选择上传的配音素材后预览无声音的问题
2026-08-25 16:08:50 +08:00

1429 lines
56 KiB
Python
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 (
_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直接返回,rendered/*已公开读)"""
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,
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"
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"
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 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 为空(使用用户默认导出配置)"""
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 == "", f"Expected empty resolution, 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}'"