0a7ae8db4f
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m8s
AI Code Review / AI Code Review (pull_request) Failing after 2m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m21s
CI/CD Pipeline / Validate - Security (push) Successful in 5m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m20s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m29s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / CI Gate (pull_request) Successful in 17s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 262h50m20s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 262h50m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 262h50m40s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 262h57m36s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 262h57m48s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 262h57m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 262h57m54s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 262h57m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 262h57m37s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 262h58m2s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 262h57m37s
CI/CD Pipeline / Deploy Production (push) Failing after 263h8m9s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 263h8m10s
CI/CD Pipeline / Build Production Web Image (push) Failing after 263h8m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 263h12m49s
CI/CD Pipeline / Build Production API Image (push) Failing after 263h8m10s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 263h14m0s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 263h14m0s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 263h14m1s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 262h57m58s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 262h58m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 262h58m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 262h57m58s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 262h58m1s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 262h57m58s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 263h16m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 263h16m38s
CI/CD Pipeline / PR Build Web Image (push) Failing after 263h16m38s
CI/CD Pipeline / Frontend Lint (push) Failing after 263h16m39s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 263h16m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 263h32m15s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 263h32m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 263h32m29s
CI/CD Pipeline / CI Gate (push) Failing after 263h42m43s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 263h24m54s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 263h25m13s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 263h32m34s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""Issue #1714:HEVC 转码后禁止兜底新建重复 READY 记录,必须回写占位 asset。
|
||
|
||
覆盖:
|
||
- 转码成功 + 占位 asset 存在(按原始 key 找到)→ 更新占位为 READY、
|
||
storage_key 改写为 *_h264,绝不 create 新记录(回归 P1 孤儿 PROCESSING bug)
|
||
- job.asset_id 透传时优先按 id 关联占位(即使 key 对不上也能命中)
|
||
- 无占位记录(旧链路)→ 兜底新建(保留兼容)
|
||
- 非 HEVC:占位同样被更新为 READY,不新建
|
||
- 无效媒体:占位标记为 ERROR,不新建 ERROR 记录
|
||
- ingest 异常:占位(按还原后的原始 key)标记 ERROR
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
# ── 与 test_ingest_hevc_transcode_task.py 相同的 worker 模块加载方式 ──
|
||
_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
|
||
from worker_app.tasks import ingest as ingest_mod # noqa: E402
|
||
|
||
from packages.domain import Asset, AssetStatus # noqa: E402
|
||
|
||
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, job):
|
||
self.job = job
|
||
self.updated = None
|
||
|
||
def get(self, job_id):
|
||
return self.job
|
||
|
||
def update(self, job):
|
||
self.updated = job
|
||
return job
|
||
|
||
|
||
class _FakeAssetRepo:
|
||
"""记录 create 调用;find_* 按内部 assets 列表查询。"""
|
||
|
||
def __init__(self, assets: list[Asset] | None = None):
|
||
self.assets = list(assets or [])
|
||
self.created: list[Asset] = []
|
||
self.updated: list[Asset] = []
|
||
|
||
def create(self, asset: Asset) -> Asset:
|
||
self.created.append(asset)
|
||
self.assets.append(asset)
|
||
return asset
|
||
|
||
def update(self, asset: Asset) -> Asset:
|
||
self.updated.append(asset)
|
||
return asset
|
||
|
||
def find_by_id(self, asset_id: str) -> Asset | None:
|
||
return next((a for a in self.assets if a.id == asset_id), None)
|
||
|
||
def find_by_storage_key(self, storage_key: str) -> Asset | None:
|
||
return next((a for a in self.assets if a.storage_key == storage_key), None)
|
||
|
||
|
||
def _make_job(asset_id: str = "", storage_key: str = "uploads/proj/IMG_2282.MOV"):
|
||
return SimpleNamespace(
|
||
id="job-1",
|
||
project_id="proj-1",
|
||
library_id="lib-1",
|
||
storage_key=storage_key,
|
||
file_hash="hash-1",
|
||
asset_id=asset_id,
|
||
status=None,
|
||
error_message=None,
|
||
result_asset_id=None,
|
||
updated_at=None,
|
||
)
|
||
|
||
|
||
def _make_placeholder(storage_key: str = "uploads/proj/IMG_2282.MOV", asset_id: str = "asset-ph"):
|
||
return Asset(
|
||
id=asset_id,
|
||
project_id="proj-1",
|
||
library_id="lib-1",
|
||
name="IMG_2282.MOV",
|
||
storage_key=storage_key,
|
||
mime_type="video/quicktime",
|
||
status=AssetStatus.PROCESSING,
|
||
file_hash="hash-1",
|
||
)
|
||
|
||
|
||
def _video_metadata(codec="hevc"):
|
||
return {
|
||
"codec": codec,
|
||
"width": 1920,
|
||
"height": 1080,
|
||
"duration": 10.0,
|
||
"size_bytes": 5 * 1024 * 1024,
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def transcode_env(tmp_path):
|
||
"""HEVC 转码成功的标准 mock 环境(同 test_ingest_hevc_transcode_task)。"""
|
||
local_file = tmp_path / "local_hevc.MOV"
|
||
local_file.write_bytes(b"fake-hevc-source")
|
||
tc_out = tmp_path / "transcode_out_h264.mp4"
|
||
|
||
control = {
|
||
"validate_ok": True,
|
||
"tc_out": tc_out,
|
||
"local_file": local_file,
|
||
"download_ok": True,
|
||
"extract_success": True,
|
||
"codec": "hevc",
|
||
"raise_in_flow": None,
|
||
}
|
||
|
||
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()
|
||
mock_file.__enter__.return_value = mock_file
|
||
mock_file.__exit__.return_value = False
|
||
return mock_file
|
||
|
||
def fake_subprocess_run(cmd, **kwargs):
|
||
if cmd and cmd[0] == "ffmpeg" and "libx264" in cmd:
|
||
Path(cmd[-1]).write_bytes(b"fake-h264-output")
|
||
return SimpleNamespace(returncode=0, stderr="")
|
||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||
|
||
control["patchers"] = {
|
||
"session": patch.object(ingest_mod, "SessionLocal", return_value=MagicMock()),
|
||
"download": patch.object(ingest_mod, "download_asset", side_effect=lambda *a, **kw: control["download_ok"]),
|
||
"upload": patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss/x"),
|
||
"metadata": patch.object(
|
||
ingest_mod,
|
||
"extract_media_metadata",
|
||
side_effect=lambda path, mt: (
|
||
(_video_metadata("h264"), control["extract_success"])
|
||
if Path(path).name == tc_out.name
|
||
else (_video_metadata(control["codec"]), control["extract_success"])
|
||
),
|
||
),
|
||
"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"),
|
||
),
|
||
}
|
||
return control
|
||
|
||
|
||
def _start(control, job, assets):
|
||
job_repo = _FakeJobRepo(job)
|
||
asset_repo = _FakeAssetRepo(assets)
|
||
patchers = dict(control["patchers"])
|
||
patchers["job_repo"] = patch.object(ingest_mod, "SQLAlchemyIngestJobRepository", return_value=job_repo)
|
||
patchers["asset_repo"] = patch.object(ingest_mod, "SQLAlchemyAssetRepository", return_value=asset_repo)
|
||
started = {name: p.start() for name, p in patchers.items()}
|
||
return started, job_repo, asset_repo
|
||
|
||
|
||
def _stop(control):
|
||
for p in control["patchers"].values():
|
||
p.stop()
|
||
|
||
|
||
class TestHEVCTranscodePlaceholderRewrite:
|
||
def test_transcode_success_updates_placeholder_no_duplicate_ready(self, transcode_env):
|
||
"""转码成功 → 占位 asset 原地更新为 READY + storage_key 改写 _h264,禁止新建。"""
|
||
control = transcode_env
|
||
placeholder = _make_placeholder()
|
||
job = _make_job() # 旧 job 无 asset_id,靠原始 key 关联
|
||
mocks, job_repo, asset_repo = _start(control, job, [placeholder])
|
||
try:
|
||
result = ingest_mod.ingest_asset("job-1")
|
||
finally:
|
||
_stop(control)
|
||
|
||
assert result["status"] == "completed"
|
||
# 核心断言 1:没有新建任何 READY 记录(旧 bug 会 create 一条 _h264 READY)
|
||
assert asset_repo.created == [], "转码回写不得新建 asset 记录"
|
||
# 核心断言 2:占位被更新为 READY,且 storage_key 已是 _h264
|
||
assert len(asset_repo.updated) == 1
|
||
updated = asset_repo.updated[0]
|
||
assert updated.id == placeholder.id
|
||
assert updated.status == AssetStatus.READY
|
||
assert updated.storage_key == "uploads/proj/IMG_2282_h264.MOV"
|
||
assert updated.metadata.get("hevc_transcoded") is True
|
||
assert updated.metadata.get("original_storage_key") == "uploads/proj/IMG_2282.MOV"
|
||
# job 关联到同一条 asset
|
||
assert job_repo.updated.result_asset_id == placeholder.id
|
||
assert job_repo.updated.storage_key == "uploads/proj/IMG_2282_h264.MOV"
|
||
|
||
def test_placeholder_resolved_by_job_asset_id(self, transcode_env):
|
||
"""job.asset_id 透传时优先按 id 关联(即使 storage_key 对不上也命中)。"""
|
||
control = transcode_env
|
||
placeholder = _make_placeholder(storage_key="uploads/different/key.MOV", asset_id="asset-by-id")
|
||
job = _make_job(asset_id="asset-by-id")
|
||
_, _, asset_repo = _start(control, job, [placeholder])
|
||
try:
|
||
result = ingest_mod.ingest_asset("job-1")
|
||
finally:
|
||
_stop(control)
|
||
|
||
assert result["status"] == "completed"
|
||
assert asset_repo.created == []
|
||
assert len(asset_repo.updated) == 1
|
||
assert asset_repo.updated[0].id == "asset-by-id"
|
||
assert asset_repo.updated[0].status == AssetStatus.READY
|
||
|
||
def test_no_placeholder_fallback_creates_ready(self, transcode_env):
|
||
"""旧链路无占位记录 → 兜底新建 READY(兼容保留,但必须是唯一一条)。"""
|
||
control = transcode_env
|
||
job = _make_job(asset_id="")
|
||
_, _, asset_repo = _start(control, job, [])
|
||
try:
|
||
result = ingest_mod.ingest_asset("job-1")
|
||
finally:
|
||
_stop(control)
|
||
|
||
assert result["status"] == "completed"
|
||
assert len(asset_repo.created) == 1
|
||
created = asset_repo.created[0]
|
||
assert created.status == AssetStatus.READY
|
||
assert created.storage_key == "uploads/proj/IMG_2282_h264.MOV"
|
||
assert asset_repo.updated == []
|
||
|
||
def test_non_hevc_placeholder_updated_no_create(self, transcode_env):
|
||
"""非 HEVC(h264)不转码:占位按原始 key 找到并更新 READY,不新建。"""
|
||
control = transcode_env
|
||
control["codec"] = "h264"
|
||
placeholder = _make_placeholder()
|
||
job = _make_job()
|
||
mocks, _, asset_repo = _start(control, job, [placeholder])
|
||
try:
|
||
result = ingest_mod.ingest_asset("job-1")
|
||
finally:
|
||
_stop(control)
|
||
|
||
assert result["status"] == "completed"
|
||
assert asset_repo.created == []
|
||
assert len(asset_repo.updated) == 1
|
||
updated = asset_repo.updated[0]
|
||
assert updated.status == AssetStatus.READY
|
||
assert updated.storage_key == "uploads/proj/IMG_2282.MOV" # 未转码,key 不变
|
||
mocks["upload"].assert_not_called()
|
||
|
||
def test_invalid_media_marks_placeholder_error_no_create(self, transcode_env):
|
||
"""无效媒体:占位标记 ERROR 并 update,禁止再 create 一条 ERROR。"""
|
||
control = transcode_env
|
||
control["download_ok"] = False # 下载失败 → extract_success=False → 无效媒体路径
|
||
placeholder = _make_placeholder()
|
||
job = _make_job()
|
||
_, job_repo, asset_repo = _start(control, job, [placeholder])
|
||
try:
|
||
result = ingest_mod.ingest_asset("job-1")
|
||
finally:
|
||
_stop(control)
|
||
|
||
assert result["status"] == "failed"
|
||
assert asset_repo.created == [], "无效媒体不得新建 ERROR 记录"
|
||
assert len(asset_repo.updated) == 1
|
||
assert asset_repo.updated[0].id == placeholder.id
|
||
assert asset_repo.updated[0].status == AssetStatus.ERROR
|
||
assert job_repo.updated.result_asset_id == placeholder.id
|
||
|
||
def test_exception_path_marks_placeholder_error(self, transcode_env):
|
||
"""ingest 主流程抛异常(如元数据提取炸了)→ 占位按原始 key 找到并标 ERROR。"""
|
||
control = transcode_env
|
||
placeholder = _make_placeholder()
|
||
job = _make_job()
|
||
started, _, asset_repo = _start(control, job, [placeholder])
|
||
started["metadata"].side_effect = RuntimeError("boom in flow")
|
||
try:
|
||
result = ingest_mod.ingest_asset("job-1")
|
||
finally:
|
||
_stop(control)
|
||
|
||
assert result["status"] == "failed"
|
||
# 异常路径把占位标 ERROR(旧实现用被改写的 _h264 key 回查会落空)
|
||
error_marked = [a for a in asset_repo.assets if a.id == placeholder.id and a.status == AssetStatus.ERROR]
|
||
assert error_marked, "异常路径必须把占位 asset 标为 ERROR"
|