"""Issue #1714:任务执行前状态守卫 — 已作废消息必须丢弃,禁止非法转换后继续跑。 覆盖: - ingest_asset:job 已 failed/completed 时直接返回 discarded,不下载、不转码、不回写 - generate_video:GenerationTask 已 failed 时返回 discarded,不进入渲染 - generate_video:pending → running 标记失败(非法转换)时安全中止 """ from __future__ import annotations import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock # ── worker 模块标准加载方式 ── # 显式保存将要覆盖的注入键旧值:全量收集时更早的测试文件(如 # test_ingest_validation.py)可能已向 sys.modules 注入 worker_app.* mock, # 导入完成后必须精确恢复旧值,否则本文件的 bind 感知透传装饰器会残留, # 污染后续懒加载短路径 worker_app.celery_app 的 worker 测试。 _INJECTED_KEYS = ("worker_app.db", "worker_app.core.config", "worker_app.celery_app") _SAVED_MODULE_VALUES = {k: sys.modules.get(k) for k in _INJECTED_KEYS} _SAVED_MODULES_KEYS = set(sys.modules.keys()) _mock_db_module = MagicMock() _mock_db_module.SessionLocal = MagicMock() sys.modules["worker_app.db"] = _mock_db_module sys.modules["worker_app.core.config"] = MagicMock() _mock_celery_module = MagicMock() def _passthrough_decorator(*args, **kwargs): if len(args) == 1 and callable(args[0]): return args[0] bind = kwargs.get("bind", False) def _wrap(f): if bind: # 模拟 celery bind=True:task(task_id) 调用时注入 self(MagicMock) return lambda *a, **kw: f(MagicMock(), *a, **kw) return f return _wrap _mock_celery_module.celery_app.task = MagicMock(side_effect=_passthrough_decorator) sys.modules["worker_app.celery_app"] = _mock_celery_module _WORKER_PATH = str(Path(__file__).resolve().parents[2] / "apps" / "worker") sys.path.insert(0, _WORKER_PATH) import pytest # noqa: E402 from worker_app.tasks import ingest as ingest_mod # noqa: E402 # video_processing 相关 mock(generation 模块导入链) for _mod_name in [ "video_processing", "video_processing.ffmpeg_utils", "video_processing.oss_helpers", ]: sys.modules.setdefault(_mod_name, MagicMock()) from worker_app.tasks import generation as gen_mod # noqa: E402 from packages.domain import IngestJobStatus # noqa: E402 # 模块导入完成后立即清理:删除本次 import 新引入的模块缓存(本模块已通过名字绑定 # 持有 ingest_mod/gen_mod/IngestJobStatus,删除缓存不影响调用),再把三个注入键 # 精确恢复为注入前的旧值(旧值不存在则移除),杜绝 mock 残留污染其他 worker 测试。 for _key in list(sys.modules.keys()): if _key not in _SAVED_MODULES_KEYS and not _key.startswith("video_processing"): del sys.modules[_key] for _k, _v in _SAVED_MODULE_VALUES.items(): if _v is None: sys.modules.pop(_k, None) else: sys.modules[_k] = _v del _SAVED_MODULES_KEYS, _SAVED_MODULE_VALUES # ── ingest 守卫 ──────────────────────────────────────────────────────── class _FakeJobRepo: def __init__(self, job): self.job = job def get(self, job_id): return self.job def _make_ingest_job(status): job = MagicMock() job.id = "job-stale-1" job.storage_key = "uploads/proj/stale.mov" job.status = status job.file_hash = "h" job.asset_id = "" return job def test_ingest_discards_failed_job_message(): """job 已 failed:消息丢弃,不进入下载/转码/回写。""" job = _make_ingest_job(IngestJobStatus.FAILED) fake_session = MagicMock() _mock_db_module.SessionLocal = MagicMock(return_value=fake_session) # SQLAlchemy 仓储构造返回 fake fake_job_repo = _FakeJobRepo(job) fake_asset_repo = MagicMock() orig_job_repo = ingest_mod.SQLAlchemyIngestJobRepository orig_asset_repo = ingest_mod.SQLAlchemyAssetRepository ingest_mod.SQLAlchemyIngestJobRepository = MagicMock(return_value=fake_job_repo) ingest_mod.SQLAlchemyAssetRepository = MagicMock(return_value=fake_asset_repo) try: result = ingest_mod.ingest_asset("job-stale-1") finally: ingest_mod.SQLAlchemyIngestJobRepository = orig_job_repo ingest_mod.SQLAlchemyAssetRepository = orig_asset_repo assert result["status"] == "discarded" # 没有任何 update / commit / 下载动作 fake_session.commit.assert_not_called() fake_asset_repo.create.assert_not_called() def test_ingest_discards_completed_job_message(): job = _make_ingest_job(IngestJobStatus.COMPLETED) fake_session = MagicMock() _mock_db_module.SessionLocal = MagicMock(return_value=fake_session) fake_job_repo = _FakeJobRepo(job) orig = ingest_mod.SQLAlchemyIngestJobRepository ingest_mod.SQLAlchemyIngestJobRepository = MagicMock(return_value=fake_job_repo) ingest_mod.SQLAlchemyAssetRepository = MagicMock(return_value=MagicMock()) try: result = ingest_mod.ingest_asset("job-stale-1") finally: ingest_mod.SQLAlchemyIngestJobRepository = orig assert result["status"] == "discarded" # ── generation 守卫 ──────────────────────────────────────────────────── def _make_gen_task(status_value: str): from packages.domain import GenerationTask task = GenerationTask.create(project_id="p", asset_library_id="l", created_by_user_id="u") task.status = type(task.status)(status_value) return task def test_generate_video_discards_failed_task(monkeypatch): """GenerationTask 已 failed:直接 discarded,不加载渲染数据。""" failed_task = _make_gen_task("failed") fake_repo = MagicMock() fake_repo.get.return_value = failed_task fake_session = MagicMock() _mock_db_module.SessionLocal = MagicMock(return_value=fake_session) import packages.adapters.sqlalchemy_impl.generation_task_repository as gen_repo_mod orig = gen_repo_mod.SQLAlchemyGenerationTaskRepository gen_repo_mod.SQLAlchemyGenerationTaskRepository = MagicMock(return_value=fake_repo) update_status_mock = MagicMock(return_value=False) monkeypatch.setattr(gen_mod, "_update_task_status", update_status_mock) monkeypatch.setattr( gen_mod, "_load_task_info", lambda task_id: { "project_id": "p", "template_id": "", "task_asset_ids": [], "batch_id": "", "user_id": "u", "mode": "one_take", }, ) monkeypatch.setattr(gen_mod, "_flush_logs", lambda *a, **k: None) task_fn = gen_mod.generate_video if hasattr(task_fn, "__wrapped__"): task_fn = task_fn.__wrapped__ try: result = task_fn("task-stale-1") finally: gen_repo_mod.SQLAlchemyGenerationTaskRepository = orig assert result["status"] == "discarded" # 状态守卫命中终态,根本不应尝试 mark_processing update_status_mock.assert_not_called() def test_generate_video_aborts_when_claim_fails(monkeypatch): """pending 但 mark_processing 返回 False(状态机非法转换)时安全中止。""" pending_task = _make_gen_task("pending") fake_repo = MagicMock() fake_repo.get.return_value = pending_task fake_session = MagicMock() _mock_db_module.SessionLocal = MagicMock(return_value=fake_session) import packages.adapters.sqlalchemy_impl.generation_task_repository as gen_repo_mod orig = gen_repo_mod.SQLAlchemyGenerationTaskRepository gen_repo_mod.SQLAlchemyGenerationTaskRepository = MagicMock(return_value=fake_repo) monkeypatch.setattr( gen_mod, "_load_task_info", lambda task_id: { "project_id": "p", "template_id": "", "task_asset_ids": [], "batch_id": "", "user_id": "u", "mode": "one_take", }, ) monkeypatch.setattr(gen_mod, "_flush_logs", lambda *a, **k: None) # 模拟 mark_processing 失败(failed→running 非法转换被 _update_task_status 吞掉返回 False) update_status_mock = MagicMock(return_value=False) monkeypatch.setattr(gen_mod, "_update_task_status", update_status_mock) render_mock = MagicMock(side_effect=AssertionError("must not render")) monkeypatch.setattr(gen_mod, "_render_from_edit_plan", render_mock) task_fn = gen_mod.generate_video if hasattr(task_fn, "__wrapped__"): task_fn = task_fn.__wrapped__ try: result = task_fn("task-claim-fail") finally: gen_repo_mod.SQLAlchemyGenerationTaskRepository = orig assert result["status"] == "discarded" render_mock.assert_not_called()