"""#1743 批量生成无可用 plan 守卫。 新规则(P0 降重):count>1 批量生成时必须存在源 plan(前端传入或按模板兜底 解析到最新 plan),为每个变体独立选片;**无任何可用 plan 时直接 4xx 中断、 不创建任务**,严禁 N 个任务兜底共用同一 plan 产出同源成片。 N=1 单视频不受影响(无 plan 时走原有单任务流程)。 """ import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from fastapi import HTTPException REPO_ROOT = Path(__file__).resolve().parents[2] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) def _make_user(): return SimpleNamespace(user=SimpleNamespace(id="user-1")) def _make_request(count, **overrides): from app.schemas.generation_task import CreateGenerationTaskRequest fields = dict( project_id="proj-1", asset_library_id="lib-1", strategy_id="one_take", asset_ids=["a1"], count=count, source_edit_plan_id="", ) fields.update(overrides) return CreateGenerationTaskRequest(**fields) def _common_patches(latest_plan=None): """构造通用 patch 上下文(repo/usecase/enqueue 等)。latest_plan 为模板兜底 plan 或 None。""" repo = MagicMock() repo.count_pending_by_user.return_value = 0 repo.count_pending_total.return_value = 0 repo.create.side_effect = lambda t: t repo.update.side_effect = lambda t: t created = [] def _fake_execute(cmd): task = MagicMock() task.id = f"task-{len(created) + 1}" task.source_edit_plan_id = cmd.source_edit_plan_id task.status = "pending" task.progress = 0.0 task.strategy_id = "one_take" task.error_message = "" task.cover_url = "" task.title_config = {} task.created_at = None task.batch_id = "batch-1" created.append(task) return task db = MagicMock() # 模板兜底查最新 plan:返回 latest_plan(None 表示查不到) db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest_plan mock_uc = patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase") other_patches = [ patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True), patch("app.api.routes.generation_tasks._writeback_edit_plan_config"), patch( "app.api.routes.generation_tasks._resolve_project_and_library", return_value=("proj-1", ""), ), ] return repo, db, created, mock_uc, other_patches, _fake_execute class TestBatchNoSourcePlanGuard: def test_count3_without_any_plan_rejects_4xx_and_creates_nothing(self): """count=3 且无源 plan、模板兜底也查不到 → 400 中断,零任务创建(严禁同源成片)。""" from app.api.routes.generation_tasks import create_generation_task repo, db, created, mock_uc, other_patches, fake_exec = _common_patches(latest_plan=None) MockUC = mock_uc.start() MockUC.return_value.execute.side_effect = fake_exec for p in other_patches: p.start() all_patches = [mock_uc] + other_patches try: with pytest.raises(HTTPException) as exc_info: create_generation_task( _make_request(3), authenticated_user=_make_user(), generation_task_repository=repo, project_repository=MagicMock(), asset_repository=MagicMock(), asset_library_repository=MagicMock(), db=db, ) assert exc_info.value.status_code == 400 finally: for p in reversed(all_patches): p.stop() assert len(created) == 0, "无 plan 批量必须零任务创建" def test_count1_without_plan_still_works(self): """N=1 单视频无 plan 不触发批量守卫(向后兼容,不 4xx)。""" from app.api.routes.generation_tasks import create_generation_task repo, db, created, mock_uc, other_patches, fake_exec = _common_patches(latest_plan=None) MockUC = mock_uc.start() MockUC.return_value.execute.side_effect = fake_exec for p in other_patches: p.start() all_patches = [mock_uc] + other_patches try: create_generation_task( _make_request(1), authenticated_user=_make_user(), generation_task_repository=repo, project_repository=MagicMock(), asset_repository=MagicMock(), asset_library_repository=MagicMock(), db=db, ) except HTTPException as e: assert e.status_code != 400, f"N=1 不应被批量守卫拦截: {e.detail}" except Exception: # MagicMock 任务对象下游响应序列化可能抛 ValidationError 等,与批量守卫无关; # 任务已在 usecase.execute 中创建,下方断言 created==1 即证明守卫未拦截。 pass finally: for p in reversed(all_patches): p.stop() assert len(created) == 1