fe79cd90c3
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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web 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 API 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m5s
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 3m35s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m6s
AI Code Review / AI Code Review (pull_request) Successful in 4m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m50s
该文件在模块导入时向 sys.modules 注入 worker_app.db/celery_app/core.config、 video_processing.oss_helpers/thumbnail_generator 的 MagicMock(用于让 ingest 任务模块可导入),但注入后从不清理。pytest-xdist 下同一 worker 进程后续执行的测试文件会直接拿到 MagicMock 版本的模块——表现为 test_thumbnail_generator 的 50 个纯逻辑用例断言到 <MagicMock> 而失败 (执行顺序相关,故为非确定性失败)。 添加 module 作用域 autouse fixture,在本文件测试跑完后移除 mock 并 重新导入真实模块,与 test_dedup_pure/test_generated_video_creation_logic 的清理模式一致。 配套 #1563(test_dedup_engine 父包 mock 污染)。
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""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_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
|
||
|
||
|
||
# ── 模块级 mock 的清理 ─────────────────────────────────────────────────────
|
||
# 上方在模块导入时往 sys.modules 注入了 worker_app.* / video_processing.*
|
||
# 的 MagicMock(为了让 ingest_mod 可导入)。模块级注入不会随测试结束自动
|
||
# 撤销,会污染同一 pytest 进程(含 xdist worker)后续执行的其他测试文件:
|
||
# 例如 test_thumbnail_generator.py 的纯逻辑用例会从 sys.modules 拿到
|
||
# MagicMock 版本的 video_processing.thumbnail_generator,全部断言失败。
|
||
# 用 module 作用域 autouse fixture 在本文件测试跑完后恢复真实模块。
|
||
_MOCKED_MODULE_NAMES = (
|
||
"worker_app.db",
|
||
"worker_app.core.config",
|
||
"worker_app.celery_app",
|
||
"video_processing.oss_helpers",
|
||
"video_processing.thumbnail_generator",
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="module", autouse=True)
|
||
def _restore_sys_modules_after_module_tests():
|
||
yield
|
||
for _name in _MOCKED_MODULE_NAMES:
|
||
sys.modules.pop(_name, None)
|
||
# 重新导入真实模块,让后续测试文件拿到真实实现
|
||
import importlib
|
||
|
||
importlib.invalidate_caches()
|
||
for _name in ("video_processing.oss_helpers", "video_processing.thumbnail_generator"):
|
||
try:
|
||
importlib.import_module(_name)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
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.object(
|
||
sys.modules["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),
|
||
# 缩略图生成跳过
|
||
"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
|