"""预览生成(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}'"