"""ingest_asset 任务中 HEVC 转码主流程的任务级单元测试。 通过 mock subprocess / repository / OSS,验证: - 转码成功 + 方向校验通过 → storage_key 改写为 *_h264 - 方向校验失败(竖屏转出横屏)→ 降级原文件,storage_key 不变,error 日志 - ffmpeg 非零退出 → 降级原文件 - 非 HEVC 编码 → 不触发转码 """ from __future__ import annotations import sys import tempfile from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch # 在 import worker_app 模块前 mock 掉数据库连接和 celery(同 test_ingest_validation.py) # 注意:只 mock worker_app 的依赖(db / celery),不 mock video_processing.*—— # 后者属于共享工具包,sys.modules 注入即使清理也会因 video_processing/__init__.py # 的 `from . import oss_helpers` 导致包属性残留为 MagicMock,跨文件污染 # test_oss_helpers_pure / test_oss_upload_crash_fix / test_p02_worker_oss_fix # 等测试文件(39 个用例失败)。改为直接 import 真实模块,patch.object 打补丁。 _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] return lambda f: f _mock_celery_module.celery_app.task = MagicMock(side_effect=_passthrough_decorator) sys.modules["worker_app.celery_app"] = _mock_celery_module sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker")) import pytest # noqa: E402 # ── 先导入真实 video_processing 子模块,再导入 ingest_mod ── # 不使用 sys.modules 注入 mock,避免 video_processing/__init__.py 的 # `from . import oss_helpers` 与 mock 注入/清理产生时序冲突。 from worker_app.tasks import ingest as ingest_mod # noqa: E402 # ── 恢复 worker_app mock,避免泄漏到其他测试文件 ── for _key in list(sys.modules.keys()): if _key not in _SAVED_MODULES_KEYS and not _key.startswith("video_processing"): del sys.modules[_key] del _SAVED_MODULES_KEYS class _FakeJobRepo: def __init__(self, db): self.initial_job = SimpleNamespace( id="job-1", project_id="proj-1", library_id="lib-1", storage_key="uploads/proj/IMG_2281.MOV", file_hash="hash-1", status=None, error_message=None, result_asset_id=None, updated_at=None, ) self.updated_job = None def get(self, job_id): return self.initial_job def update(self, job): # 生产代码在同一 job 对象上原地修改属性后传入 update; # 这里捕获引用,断言时读最终状态 self.updated_job = job @property def final_job(self): return self.updated_job or self.initial_job class _FakeAssetRepo: def __init__(self, db): self.created = None def create(self, asset): self.created = asset def _video_metadata(codec="hevc"): return { "codec": codec, "width": 1920, "height": 1080, "duration": 10.0, "size_bytes": 5 * 1024 * 1024, } @pytest.fixture def task_env(tmp_path): """统一构造 ingest_asset 主流程的 mock 环境。返回控制句柄。 测试中用 mocks = _start_patches(control) 启动,断言必须用 mocks["upload"] 等 start() 返回的 mock;不能在 stop() 后读模块 属性(stop 后属性恢复为原 auto-mock,调用记录为 0)。 """ local_file = tmp_path / "local_hevc.MOV" local_file.write_bytes(b"fake-hevc-source") tc_out = tmp_path / "transcode_out_h264.mp4" control = { "rotation_source": 90, # 源文件 rotation;None=横屏无 side data "transcode_rc": 0, "transcode_produces_file": True, "validate_ok": True, "upload_url": "https://oss.example.com/x_h264.MOV", "codec": "hevc", "source_dims": (1920, 1080), "tc_out": tc_out, "local_file": local_file, } def fake_probe_rotation(path): if Path(path).name == tc_out.name: return None # 产物无 side data return control["rotation_source"] def fake_probe_dimensions(path): if Path(path).name == tc_out.name: return (1080, 1920) if control["validate_ok"] else (1920, 1080) return control["source_dims"] control["subprocess_calls"] = [] def fake_subprocess_run(cmd, **kwargs): control["subprocess_calls"].append(list(cmd[:3])) if cmd and cmd[0] == "ffmpeg" and "libx264" in cmd: if control["transcode_rc"] != 0: return SimpleNamespace(returncode=control["transcode_rc"], stderr="boom") if control["transcode_produces_file"]: Path(cmd[-1]).write_bytes(b"fake-h264-output") return SimpleNamespace(returncode=0, stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") def fake_ntf(*args, **kwargs): mock_file = MagicMock() mock_file.name = str(tc_out) if kwargs.get("suffix") == "_h264.mp4" else str(local_file) mock_file.close = MagicMock() # with ... as tmp: 让 __enter__ 返回自身,tmp.name 才是上面设置的路径 mock_file.__enter__.return_value = mock_file mock_file.__exit__.return_value = False return mock_file job_repo = _FakeJobRepo(db=None) asset_repo = _FakeAssetRepo(db=None) control["patchers"] = { "session": patch.object(ingest_mod, "SessionLocal", return_value=MagicMock()), "job_repo": patch.object(ingest_mod, "SQLAlchemyIngestJobRepository", return_value=job_repo), "asset_repo": patch.object(ingest_mod, "SQLAlchemyAssetRepository", return_value=asset_repo), "download": patch.object(ingest_mod, "download_asset", return_value=True), "upload": patch( "video_processing.oss_helpers.upload_to_oss", return_value=control["upload_url"], ), "metadata": patch.object( ingest_mod, "extract_media_metadata", side_effect=lambda path, mt: ( (_video_metadata("h264"), True) if Path(path).name == tc_out.name else (_video_metadata(control["codec"]), True) ), ), "rotation": patch.object(ingest_mod, "probe_rotation", side_effect=fake_probe_rotation), "dimensions": patch.object(ingest_mod, "probe_dimensions", side_effect=fake_probe_dimensions), "validate": patch.object( ingest_mod, "validate_transcode_output", side_effect=lambda p, portrait: control["validate_ok"], ), "subprocess": patch.object(ingest_mod.subprocess, "run", side_effect=fake_subprocess_run), "ntf": patch.object(tempfile, "NamedTemporaryFile", side_effect=fake_ntf), # 缩略图生成跳过——字符串目标,patch.start 时从 sys.modules 取真实模块再打补丁 "thumb": patch( "video_processing.thumbnail_generator.extract_first_frame", side_effect=RuntimeError("skip thumb"), ), } control["job_repo"] = job_repo control["asset_repo"] = asset_repo return control def _start_patches(control): """启动全部 patcher,返回具名 mock dict(调用记录都在这些 mock 上)。""" return {name: p.start() for name, p in control["patchers"].items()} def _stop_patches(control): for p in control["patchers"].values(): p.stop() class TestIngestHEVCTranscodeFlow: def test_success_rewrites_storage_key(self, task_env): """竖屏 HEVC 转码+校验通过 → storage_key 改写为 *_h264.MOV,asset READY 入库。""" mocks = _start_patches(task_env) try: result = ingest_mod.ingest_asset("job-1") finally: _stop_patches(task_env) assert result["status"] == "completed" assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281_h264.MOV" assert task_env["asset_repo"].created is not None # 转码产物上传 OSS 恰好一次,且上传的是 *_h264.MOV 新 key mocks["upload"].assert_called_once() uploaded_path, uploaded_key = mocks["upload"].call_args.args assert uploaded_key == "uploads/proj/IMG_2281_h264.MOV" assert str(uploaded_path).endswith("_h264.mp4") def test_validation_failure_keeps_original_file(self, task_env): """竖屏转出横屏(校验失败)→ 降级原文件,storage_key 不变,打 error 日志。""" task_env["validate_ok"] = False mocks = _start_patches(task_env) error_mock = MagicMock() try: with patch.object(ingest_mod.logger, "error", error_mock): result = ingest_mod.ingest_asset("job-1") finally: _stop_patches(task_env) assert result["status"] == "completed" assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281.MOV" assert error_mock.called assert "方向/维度校验失败" in error_mock.call_args[0][0] # 校验失败:转码产物不得上传 OSS,杜绝横屏文件覆盖 mocks["upload"].assert_not_called() def test_ffmpeg_nonzero_keeps_original(self, task_env): """ffmpeg 返回非零 → 降级原文件,storage_key 不变。""" task_env["transcode_rc"] = 1 mocks = _start_patches(task_env) try: result = ingest_mod.ingest_asset("job-1") finally: _stop_patches(task_env) assert result["status"] == "completed" assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281.MOV" mocks["upload"].assert_not_called() def test_physical_portrait_no_rotation_still_transcodes(self, task_env): """物理竖屏(存储 1080x1920、rotation=None,Android 风格)也必须判定竖屏 并转码改写 storage_key——回归旧逻辑只看 rotation 误判横屏的 bug。""" task_env["source_dims"] = (1080, 1920) task_env["rotation_source"] = None mocks = _start_patches(task_env) try: result = ingest_mod.ingest_asset("job-1") finally: _stop_patches(task_env) assert result["status"] == "completed" assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281_h264.MOV" mocks["upload"].assert_called_once() # 统一滤镜按长边 1920 封顶(横/竖分支都在),不应再出现按短边 1080 的旧表达式 cmds = [] for call in mocks["subprocess"].call_args_list: cmd = call.args[0] if call.args else call.kwargs.get("cmd", []) cmds.append(cmd) vfs = [str(c) for c in cmds if c and c[0] == "ffmpeg" and "libx264" in c] assert vfs, "应执行 libx264 转码" assert any("min(1920" in vf for vf in vfs), f"应使用长边1920封顶滤镜: {vfs[0]}" assert all("gt(ih,1080)" not in vf for vf in vfs), "不应再用短边1080旧表达式" def test_non_hevc_no_transcode(self, task_env): """非 HEVC 编码(h264)→ 不触发 ffmpeg 转码。""" task_env["codec"] = "h264" task_env["rotation_source"] = None mocks = _start_patches(task_env) try: result = ingest_mod.ingest_asset("job-1") finally: _stop_patches(task_env) assert result["status"] == "completed" assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281.MOV" mocks["upload"].assert_not_called() # 所有 subprocess 调用都不应是 ffmpeg 转码 for call in mocks["subprocess"].call_args_list: cmd = call.args[0] if call.args else call.kwargs.get("cmd", []) assert "libx264" not in cmd