Files
xiaoxia-saas/tests/unit/test_batch_download.py
xiaoxia 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
fix(test): 修复 batch_download session 测试全量跑时 flaky 失败 (#1409)
2026-08-17 21:08:52 +08:00

504 lines
17 KiB
Python
Executable File
Raw Permalink 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.
"""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