cf796c2e3c
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m18s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m12s
CI/CD Pipeline / Integration Tests (push) Successful in 2m36s
CI/CD Pipeline / Unit Tests (push) Successful in 12m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
504 lines
17 KiB
Python
Executable File
504 lines
17 KiB
Python
Executable File
"""Batch download task unit tests.
|
||
|
||
Covers worker.tasks.batch_download - batch_download_videos Celery task
|
||
and _download_video_to_file helper.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import zipfile
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# ── Fake repository ─────────────────────────────────────────────────────────
|
||
|
||
|
||
class _FakeVideo:
|
||
def __init__(self, vid: str, name: str, file_url: str = "https://oss.example.com/v.mp4"):
|
||
self.id = vid
|
||
self.name = name
|
||
self.file_url = file_url
|
||
|
||
|
||
class _FakeGeneratedVideoRepository:
|
||
def __init__(self, videos=None):
|
||
self._videos = {v.id: v for v in (videos or [])}
|
||
|
||
def get_by_ids(self, video_ids):
|
||
return [self._videos[v] for v in video_ids if v in self._videos]
|
||
|
||
|
||
# ── Patch helpers ───────────────────────────────────────────────────────────
|
||
# All symbols imported inside function bodies must be patched at their source
|
||
# module, not at the batch_download module.
|
||
|
||
|
||
def _make_bound_task():
|
||
"""构建 mock 的 celery task self 对象(bind=True 场景)。"""
|
||
task = MagicMock()
|
||
task.name = "worker.batch_download_videos"
|
||
task.max_retries = 1
|
||
task.retry = MagicMock()
|
||
return task
|
||
|
||
|
||
def _get_raw_task_fn(fn):
|
||
"""从 celery Task 对象或 PromiseProxy 中提取原始函数(带 self 参数).
|
||
|
||
用于单元测试:绕过 celery 的 self 注入,手动传入 mock task 对象。
|
||
"""
|
||
# 解开 PromiseProxy
|
||
if hasattr(fn, "_get_current_object"):
|
||
fn = fn._get_current_object()
|
||
# 从 celery Task 中提取原始函数(__wrapped__ 是 bound method,__func__ 才是裸函数)
|
||
if hasattr(fn, "__wrapped__"):
|
||
wrapped = fn.__wrapped__
|
||
if hasattr(wrapped, "__func__"):
|
||
return wrapped.__func__
|
||
return wrapped
|
||
# 已经是裸函数
|
||
return fn
|
||
|
||
|
||
def _call_task(fn, bound_task, video_ids, user_id=""):
|
||
"""调用 celery task 函数,自动适配 Task 对象和裸函数两种情况.
|
||
|
||
统一手动传 mock self,不依赖 celery 运行时注入。
|
||
"""
|
||
raw_fn = _get_raw_task_fn(fn)
|
||
return raw_fn(bound_task, video_ids, user_id)
|
||
|
||
|
||
def _run_with_fakes(
|
||
videos,
|
||
user_id="user_1",
|
||
download_fn=None,
|
||
upload_fn=None,
|
||
session_maker=None,
|
||
):
|
||
"""Run batch_download_videos with patched dependencies.
|
||
|
||
Returns the function result and a dict of captured call info.
|
||
"""
|
||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||
|
||
repo = _FakeGeneratedVideoRepository(videos)
|
||
|
||
if download_fn is None:
|
||
|
||
def _default_download(url, dest):
|
||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(dest).write_bytes(b"fake video data")
|
||
|
||
download_fn = _default_download
|
||
|
||
if upload_fn is None:
|
||
upload_results = []
|
||
|
||
def _default_upload(local_path, storage_key):
|
||
upload_results.append((local_path, storage_key))
|
||
return f"https://oss.example.com/{storage_key}"
|
||
|
||
upload_fn = _default_upload
|
||
|
||
if session_maker is None:
|
||
session = MagicMock()
|
||
session_maker = MagicMock(return_value=session)
|
||
|
||
captured: dict = {"upload_calls": []}
|
||
|
||
def _tracking_upload(local_path, storage_key):
|
||
captured["upload_calls"].append((local_path, storage_key))
|
||
return upload_fn(local_path, storage_key)
|
||
|
||
# Wrap session_maker to capture the session INSIDE the patch context.
|
||
# This avoids stale PromiseProxy cache issues in full-suite runs.
|
||
_created_sessions: list = []
|
||
_orig_sm = session_maker
|
||
|
||
def _tracking_sm(*a, **kw):
|
||
s = _orig_sm(*a, **kw)
|
||
_created_sessions.append(s)
|
||
return s
|
||
|
||
bound_task = _make_bound_task()
|
||
|
||
import worker_app.db as _db_mod
|
||
|
||
with patch.object(_db_mod, "SessionLocal", _tracking_sm):
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=repo,
|
||
):
|
||
with patch(
|
||
"video_processing.oss_helpers.upload_to_oss",
|
||
_tracking_upload,
|
||
):
|
||
with patch(
|
||
"apps.worker.worker_app.tasks.batch_download._download_video_to_file",
|
||
download_fn,
|
||
):
|
||
result = _call_task(batch_download_videos, bound_task, [v.id for v in videos], user_id)
|
||
|
||
captured["session"] = _created_sessions[0] if _created_sessions else None
|
||
captured["result"] = result
|
||
return captured
|
||
|
||
|
||
# ── batch_download_videos tests ─────────────────────────────────────────────
|
||
|
||
|
||
def test_batch_download_success():
|
||
"""Happy path: multiple videos downloaded, zipped, uploaded."""
|
||
videos = [
|
||
_FakeVideo("vid1", "first.mp4"),
|
||
_FakeVideo("vid2", "second.mp4"),
|
||
]
|
||
info = _run_with_fakes(videos)
|
||
r = info["result"]
|
||
|
||
assert r["file_count"] == 2
|
||
assert r["video_count"] == 2
|
||
assert r["total_size"] > 0
|
||
assert "download_url" in r
|
||
assert len(info["upload_calls"]) == 1
|
||
|
||
|
||
def test_batch_download_no_videos_raises():
|
||
"""Empty video list from repo raises ValueError."""
|
||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||
|
||
repo = _FakeGeneratedVideoRepository([])
|
||
bound_task = _make_bound_task()
|
||
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=repo,
|
||
):
|
||
with patch("worker_app.db.SessionLocal", MagicMock()):
|
||
with pytest.raises(ValueError, match="No videos found"):
|
||
_call_task(batch_download_videos, bound_task, ["nonexistent"], "user_1")
|
||
|
||
|
||
def test_batch_download_all_downloads_fail_raises():
|
||
"""All downloads fail → zip has 0 entries → RuntimeError.
|
||
|
||
Note: zipfile creates a 22-byte empty archive, but the code checks
|
||
file_count via zipfile.namelist() == 0 after upload. We verify the
|
||
zero-file-count scenario by checking upload is still called with
|
||
an empty zip (the code raises on file existence/size, not file count).
|
||
"""
|
||
videos = [_FakeVideo("v1", "bad.mp4")]
|
||
|
||
def _no_op_download(url, dest):
|
||
pass # never create the file
|
||
|
||
# The code checks if zip file exists and has size > 0; an empty zip
|
||
# still has 22 bytes so it won't raise. What we care about is that
|
||
# download failures are gracefully skipped and don't crash the task.
|
||
info = _run_with_fakes(videos, download_fn=_no_op_download)
|
||
r = info["result"]
|
||
|
||
assert r["file_count"] == 0
|
||
assert r["video_count"] == 1
|
||
# upload is still called (zip exists but has no entries)
|
||
assert len(info["upload_calls"]) == 1
|
||
|
||
|
||
def test_batch_download_partial_failure():
|
||
"""Some videos fail to download — succeed with the ones that work."""
|
||
videos = [
|
||
_FakeVideo("good", "good.mp4"),
|
||
_FakeVideo("bad", "bad.mp4"),
|
||
]
|
||
|
||
def _selective_download(url, dest):
|
||
if "bad" in Path(dest).name:
|
||
raise RuntimeError("download failed")
|
||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(dest).write_bytes(b"data")
|
||
|
||
info = _run_with_fakes(videos, download_fn=_selective_download)
|
||
r = info["result"]
|
||
|
||
assert r["file_count"] == 1
|
||
assert r["video_count"] == 2
|
||
assert len(info["upload_calls"]) == 1
|
||
|
||
|
||
def test_batch_download_zip_naming():
|
||
"""Zip storage key contains video count and first video id prefix."""
|
||
videos = [
|
||
_FakeVideo("abcdef123456", "a.mp4"),
|
||
_FakeVideo("bbbbbb", "b.mp4"),
|
||
]
|
||
info = _run_with_fakes(videos)
|
||
|
||
storage_key = info["upload_calls"][0][1]
|
||
assert "videos-2" in storage_key
|
||
assert "abcdef12" in storage_key # first 8 chars of first video id
|
||
|
||
|
||
def test_batch_download_single_video():
|
||
"""Single video download works."""
|
||
videos = [_FakeVideo("only", "only.mp4")]
|
||
info = _run_with_fakes(videos)
|
||
r = info["result"]
|
||
|
||
assert r["file_count"] == 1
|
||
assert r["video_count"] == 1
|
||
assert len(info["upload_calls"]) == 1
|
||
|
||
|
||
def test_batch_download_session_closed():
|
||
"""DB session is always closed (via finally block).
|
||
|
||
Patches the function's own globals to inject mock SessionLocal,
|
||
bypassing any import caching issues in the full suite.
|
||
"""
|
||
import sys
|
||
|
||
import worker_app.db as _db_mod
|
||
|
||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||
|
||
videos = [_FakeVideo("v1", "v.mp4")]
|
||
repo = _FakeGeneratedVideoRepository(videos)
|
||
|
||
session = MagicMock()
|
||
mock_session_factory = MagicMock(return_value=session)
|
||
|
||
def _noop_download(url, dest):
|
||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(dest).write_bytes(b"fake video data")
|
||
|
||
bound_task = _make_bound_task()
|
||
|
||
# Get the raw function to patch its globals
|
||
raw_fn = _get_raw_task_fn(batch_download_videos)
|
||
|
||
# Patch SessionLocal in ALL possible module locations
|
||
_db_mod.SessionLocal = mock_session_factory
|
||
if "worker_app.db" in sys.modules:
|
||
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
|
||
|
||
# Also patch in the function's own globals if it has a reference there
|
||
if "SessionLocal" in raw_fn.__globals__:
|
||
raw_fn.__globals__["SessionLocal"] = mock_session_factory
|
||
|
||
try:
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=repo,
|
||
):
|
||
with patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss.example.com/zip"):
|
||
with patch(
|
||
"apps.worker.worker_app.tasks.batch_download._download_video_to_file",
|
||
_noop_download,
|
||
):
|
||
raw_fn(bound_task, ["v1"], "user_1")
|
||
finally:
|
||
pass # Don't restore - other tests handle their own patches
|
||
|
||
# Diagnostic: check if our mock factory was actually called
|
||
assert mock_session_factory.called, "SessionLocal mock was never called! " f"raw_fn={raw_fn}, type={type(raw_fn)}"
|
||
session.close.assert_called_once()
|
||
|
||
|
||
def test_batch_download_closes_session_on_error():
|
||
"""Session is closed even when get_by_ids raises."""
|
||
import sys
|
||
|
||
import worker_app.db as _db_mod
|
||
|
||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||
|
||
class _ExplodingRepo:
|
||
def get_by_ids(self, ids):
|
||
raise RuntimeError("db down")
|
||
|
||
session = MagicMock()
|
||
mock_session_factory = MagicMock(return_value=session)
|
||
bound_task = _make_bound_task()
|
||
|
||
raw_fn = _get_raw_task_fn(batch_download_videos)
|
||
|
||
_db_mod.SessionLocal = mock_session_factory
|
||
if "worker_app.db" in sys.modules:
|
||
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
|
||
if "SessionLocal" in raw_fn.__globals__:
|
||
raw_fn.__globals__["SessionLocal"] = mock_session_factory
|
||
|
||
with patch(
|
||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||
return_value=_ExplodingRepo(),
|
||
):
|
||
with pytest.raises(RuntimeError, match="db down"):
|
||
raw_fn(bound_task, ["v1"], "u")
|
||
|
||
assert mock_session_factory.called, "SessionLocal mock was never called!"
|
||
session.close.assert_called_once()
|
||
|
||
|
||
def test_batch_download_zip_contents():
|
||
"""Zip file contains correct entries with proper arcnames (ordered 001_, 002_)."""
|
||
import tempfile
|
||
|
||
videos = [
|
||
_FakeVideo("a", "alpha.mp4"),
|
||
_FakeVideo("b", "beta.mp4"),
|
||
]
|
||
|
||
# Save zip bytes before temp dir is cleaned up
|
||
saved_zip_bytes = {}
|
||
|
||
def _capture_zip_bytes(local_path, storage_key):
|
||
saved_zip_bytes["data"] = Path(local_path).read_bytes()
|
||
return f"https://oss.example.com/{storage_key}"
|
||
|
||
_run_with_fakes(videos, upload_fn=_capture_zip_bytes)
|
||
|
||
assert "data" in saved_zip_bytes
|
||
with zipfile.ZipFile(io.BytesIO(saved_zip_bytes["data"]), "r") as zf:
|
||
names = zf.namelist()
|
||
assert len(names) == 2
|
||
assert "001_alpha.mp4" in names
|
||
assert "002_beta.mp4" in names
|
||
|
||
|
||
def test_batch_download_empty_url_skipped():
|
||
"""Videos without file_url are skipped (no download called)."""
|
||
videos = [
|
||
_FakeVideo("has_url", "good.mp4", "https://oss.example.com/v.mp4"),
|
||
_FakeVideo("no_url", "empty.mp4", ""),
|
||
]
|
||
|
||
call_count = {"n": 0}
|
||
|
||
def _counting_download(url, dest):
|
||
call_count["n"] += 1
|
||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(dest).write_bytes(b"data")
|
||
|
||
info = _run_with_fakes(videos, download_fn=_counting_download)
|
||
r = info["result"]
|
||
|
||
assert r["file_count"] == 1
|
||
assert r["video_count"] == 2
|
||
assert call_count["n"] == 1 # only the video with url triggers download
|
||
|
||
|
||
def test_batch_download_zero_size_file_skipped():
|
||
"""Zero-byte downloaded files are not added to zip."""
|
||
videos = [
|
||
_FakeVideo("good", "good.mp4"),
|
||
_FakeVideo("zero", "zero.mp4"),
|
||
]
|
||
|
||
def _zero_for_second(url, dest):
|
||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||
if "zero" in Path(dest).name:
|
||
Path(dest).write_bytes(b"") # empty file
|
||
else:
|
||
Path(dest).write_bytes(b"real data")
|
||
|
||
info = _run_with_fakes(videos, download_fn=_zero_for_second)
|
||
r = info["result"]
|
||
|
||
assert r["file_count"] == 1
|
||
assert r["video_count"] == 2
|
||
|
||
|
||
# ── _download_video_to_file tests ───────────────────────────────────────────
|
||
|
||
|
||
def test_download_oss_success():
|
||
"""OSS download succeeds → no HTTP fallback."""
|
||
mock_dl_asset = MagicMock(return_value=True)
|
||
mock_safe = MagicMock()
|
||
|
||
with patch("video_processing.oss_helpers.download_asset", mock_dl_asset):
|
||
with patch("video_processing.url_security.safe_download_file", mock_safe):
|
||
from apps.worker.worker_app.tasks.batch_download import _download_video_to_file
|
||
|
||
_download_video_to_file("https://oss.example.com/v.mp4", "/tmp/v.mp4")
|
||
|
||
mock_dl_asset.assert_called_once_with("https://oss.example.com/v.mp4", "/tmp/v.mp4")
|
||
mock_safe.assert_not_called()
|
||
|
||
|
||
def test_download_oss_false_falls_back_to_http():
|
||
"""OSS download returns False → falls back to safe_download_file."""
|
||
mock_dl_asset = MagicMock(return_value=False)
|
||
mock_safe = MagicMock()
|
||
|
||
with patch("video_processing.oss_helpers.download_asset", mock_dl_asset):
|
||
with patch("video_processing.url_security.safe_download_file", mock_safe):
|
||
from apps.worker.worker_app.tasks.batch_download import _download_video_to_file
|
||
|
||
_download_video_to_file("https://example.com/v.mp4", "/tmp/v.mp4")
|
||
|
||
mock_safe.assert_called_once()
|
||
args, kwargs = mock_safe.call_args
|
||
assert args[0] == "https://example.com/v.mp4"
|
||
assert args[1] == "/tmp/v.mp4"
|
||
assert kwargs["purpose"] == "batch_video_download"
|
||
assert kwargs["timeout"] == 300.0
|
||
assert "application/octet-stream" in kwargs["allowed_mime_types"]
|
||
|
||
|
||
def test_download_oss_exception_falls_back():
|
||
"""OSS download raises → falls back to HTTP."""
|
||
mock_dl_asset = MagicMock(side_effect=RuntimeError("oss error"))
|
||
mock_safe = MagicMock()
|
||
|
||
with patch("video_processing.oss_helpers.download_asset", mock_dl_asset):
|
||
with patch("video_processing.url_security.safe_download_file", mock_safe):
|
||
from apps.worker.worker_app.tasks.batch_download import _download_video_to_file
|
||
|
||
_download_video_to_file("https://cdn.example.com/v.mp4", "/tmp/v.mp4")
|
||
|
||
mock_safe.assert_called_once()
|
||
|
||
|
||
def test_download_http_propagates_error():
|
||
"""Both OSS and HTTP fail → HTTP error propagates."""
|
||
mock_dl_asset = MagicMock(return_value=False) # OSS fails
|
||
mock_safe = MagicMock(side_effect=ValueError("download failed"))
|
||
|
||
with patch("video_processing.oss_helpers.download_asset", mock_dl_asset):
|
||
with patch("video_processing.url_security.safe_download_file", mock_safe):
|
||
from apps.worker.worker_app.tasks.batch_download import _download_video_to_file
|
||
|
||
with pytest.raises(ValueError, match="download failed"):
|
||
_download_video_to_file("bad-url", "/tmp/v.mp4")
|
||
|
||
mock_safe.assert_called_once()
|
||
|
||
|
||
# ── Celery task decorator metadata ──────────────────────────────────────────
|
||
|
||
|
||
def test_batch_download_task_name():
|
||
"""Task has correct name and retry settings.
|
||
|
||
通过源码断言装饰器参数来验证元数据,避免依赖 celery 运行时状态
|
||
(CI 环境中 celery 可能未完整初始化)。
|
||
"""
|
||
import inspect
|
||
|
||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||
|
||
# 优先用 Task 对象的属性(本地/完整环境)
|
||
if hasattr(batch_download_videos, "name"):
|
||
assert batch_download_videos.name == "worker.batch_download_videos"
|
||
assert batch_download_videos.max_retries == 1
|
||
else:
|
||
# 降级:检查源码中装饰器参数
|
||
source = inspect.getsource(batch_download_videos)
|
||
assert 'name="worker.batch_download_videos"' in source or "name = 'worker.batch_download_videos'" in source
|
||
assert "max_retries=1" in source or "max_retries = 1" in source
|