Files
xiaoxia-saas/tests/unit/test_ingest_hevc_orphan_1714.py
T
xiaoxia 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
fix(upload): complete 接口幂等去重 + HEVC 转码回写占位 asset 禁止兜底新建 (#1714) (#1715)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 16:07:45 +08:00

318 lines
12 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.
"""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"