Files
xiaoxia-saas/tests/unit/test_ingest_hevc_transcode_task.py
T
xiaoxia a8122dd634
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 / Validate - Migration (alembic) (pull_request) Successful in 2m56s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m59s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m55s
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
AI Code Review / AI Code Review (pull_request) Successful in 9m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m41s
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m1s
fix(test): hevc 转码测试恢复 sys.modules 时删除全部 mock 条目
#1566 v1 的恢复块保留了 video_processing.oss_helpers / thumbnail_generator
两个 mock 条目(误以为 patch.object 需要),导致同 xdist worker 后续测试
文件 import video_processing.* 仍拿到 MagicMock——test_thumbnail_generator
50 个用例继续失败(#1561/#1562 CI 实证)。

修复:
1. 恢复时删除全部注入的 mock 条目,杜绝跨文件 mock 污染;
2. restore 后显式 import 真实 oss_helpers 模块,upload patcher 直接打在
   真实模块上(patch stop 自动还原,无污染);thumb patcher 为字符串目标,
   start 时自动导入真实模块打补丁。
2026-08-31 02:39:57 +08:00

315 lines
13 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.
"""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
# 注意:模块级 sys.modules 注入若不撤销,会污染同一 pytest 进程(含 xdist
# worker)后续收集/执行的其他测试文件——它们 from video_processing.xxx
# import 会拿到 MagicMock(表现为 test_thumbnail_generator 纯逻辑用例
# 断言到 <MagicMock>50 个用例失败,且与 xdist 分发顺序相关)。
# 因此在成功 import ingest_mod 之后立即恢复 sys.modules(同 test_dedup_pure.py
# 的做法),mock 对象仍由本文件变量/ingest_mod 引用持有,不影响本文件测试。
_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
# mock video_processing 子模块(主流程会 import 它们)
_oss_helpers_mock = MagicMock()
_thumbnail_mock = MagicMock()
sys.modules["video_processing.oss_helpers"] = _oss_helpers_mock
sys.modules["video_processing.thumbnail_generator"] = _thumbnail_mock
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
import pytest # noqa: E402
from worker_app.tasks import ingest as ingest_mod # noqa: E402
# ── 立即恢复 sys.modules,避免 mock 泄漏到其他测试文件 ──
# 本文件 patch.object 不依赖 mock 条目保留在 sys.modulesdownload_asset
# 已被 ingest_mod 顶部 from import 绑定;thumb patcher 是字符串目标,start
# 时会触发真实模块重新导入后再打补丁(真实模块 CI 可导入)。因此注入的
# mock 条目必须全部删除——若保留 video_processing.* mock,同 xdist worker
# 后续测试文件 import 仍会拿到 MagicMock#1566 v1 曾因此漏修)。
for _key in list(sys.modules.keys()):
if _key not in _SAVED_MODULES_KEYS:
del sys.modules[_key]
del _SAVED_MODULES_KEYS
# 导入真实模块供本文件 patch.object 打补丁(restore 后 sys.modules 中已无
# mock)。patch 在 stop 时会自动还原模块属性,不影响其他测试文件。
from video_processing import oss_helpers as _oss_helpers_real # noqa: E402
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, # 源文件 rotationNone=横屏无 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.object(
_oss_helpers_real,
"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),
# 缩略图生成跳过
"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.MOVasset 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=NoneAndroid 风格)也必须判定竖屏
并转码改写 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