This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
GallerySubscriber/backend/tests/services/test_gallery_dl_validation.py
T
bvandeusen 2765c464bd feat(downloader): validate and quarantine truncated files post-download
After gallery-dl returns, parse stdout for written file paths and run
the magic-byte validator on each. Files that fail are moved to
{download_path}/_quarantine/{subscription}/{platform}/ with a sidecar
JSON capturing the source URL, validation reason, and original path —
enough for an operator to redownload-from-source or delete.

Adds ErrorType.VALIDATION_FAILED. A successful gallery-dl run that
produced quarantined files is now a soft failure (success=False,
error_message names the dominant failure reason and count) so the
source's error_count ticks up and the dashboard surfaces it. Gated
by download.validate_files setting (default True).

files_quarantined and quarantined_paths are persisted into download
metadata for the UI/API to consume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:55:52 -04:00

184 lines
6.0 KiB
Python

"""Tests for GalleryDLService's post-download validation hook.
Focus: a successful gallery-dl run that wrote a truncated JPEG must
result in DownloadResult.success=False with VALIDATION_FAILED, the bad
file moved to the quarantine tree, and a sidecar JSON written next to
it. A run with no validation issues must behave exactly as before.
"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from app.services.file_validator import JPEG_HEAD, JPEG_TAIL
from app.services.gallery_dl import (
DownloadResult,
ErrorType,
GalleryDLService,
_summarize_validation_failures,
)
def _make_service(tmp_path: Path, validate: bool = True) -> GalleryDLService:
"""Service wired to a tmp download root, no real filesystem config."""
fake_settings = SimpleNamespace(
download_path=str(tmp_path / "downloads"),
config_path=str(tmp_path / "config"),
download_rate_limit=1.0,
)
Path(fake_settings.download_path).mkdir(parents=True, exist_ok=True)
Path(fake_settings.config_path).mkdir(parents=True, exist_ok=True)
with patch("app.services.gallery_dl.get_settings", return_value=fake_settings), \
patch.object(GalleryDLService, "_load_base_config", return_value={}):
return GalleryDLService(rate_limit=1.0, validate_files=validate)
def _write_jpeg(path: Path, *, truncated: bool) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
body = JPEG_HEAD + b"\x00" * 64
if not truncated:
body += JPEG_TAIL
path.write_bytes(body)
# --- _validate_and_quarantine ---
def test_quarantines_truncated_jpeg(tmp_path):
service = _make_service(tmp_path)
bad = Path(service.settings.download_path) / "Artist" / "patreon" / "01.jpg"
_write_jpeg(bad, truncated=True)
paths, failures = service._validate_and_quarantine(
written_paths=[bad],
subscription_name="Artist",
platform="patreon",
url="https://patreon.com/post/1",
)
assert len(paths) == 1
assert len(failures) == 1
assert failures[0]["format"] == "jpeg"
# Original is gone, moved to _quarantine root
assert not bad.exists()
quarantine_root = (
Path(service.settings.download_path) / "_quarantine" / "Artist" / "patreon"
)
moved = quarantine_root / "01.jpg"
assert moved.exists()
sidecar = quarantine_root / "01.jpg.quarantine.json"
assert sidecar.exists()
assert "patreon.com/post/1" in sidecar.read_text()
def test_passes_through_well_formed_files(tmp_path):
service = _make_service(tmp_path)
good = Path(service.settings.download_path) / "Artist" / "patreon" / "01.jpg"
_write_jpeg(good, truncated=False)
paths, failures = service._validate_and_quarantine(
written_paths=[good],
subscription_name="Artist",
platform="patreon",
url="https://patreon.com/post/1",
)
assert paths == []
assert failures == []
assert good.exists() # unchanged
def test_skips_unvalidatable_extensions(tmp_path):
service = _make_service(tmp_path)
sidecar = Path(service.settings.download_path) / "Artist" / "patreon" / "01.json"
sidecar.parent.mkdir(parents=True, exist_ok=True)
sidecar.write_bytes(b'{"junk": true}')
paths, _ = service._validate_and_quarantine(
written_paths=[sidecar],
subscription_name="Artist",
platform="patreon",
url="https://example/1",
)
assert paths == []
assert sidecar.exists()
def test_quarantine_filename_collision_does_not_overwrite(tmp_path):
service = _make_service(tmp_path)
quarantine_root = (
Path(service.settings.download_path) / "_quarantine" / "Artist" / "patreon"
)
quarantine_root.mkdir(parents=True, exist_ok=True)
(quarantine_root / "01.jpg").write_bytes(b"PRIOR_QUARANTINE")
bad = Path(service.settings.download_path) / "Artist" / "patreon" / "01.jpg"
_write_jpeg(bad, truncated=True)
paths, _ = service._validate_and_quarantine(
written_paths=[bad],
subscription_name="Artist",
platform="patreon",
url="https://patreon.com/2",
)
# Original quarantine file untouched, new one suffixed.
assert (quarantine_root / "01.jpg").read_bytes() == b"PRIOR_QUARANTINE"
assert (quarantine_root / "01.1.jpg").exists()
assert paths and paths[0].endswith("01.1.jpg")
# --- _written_paths ---
def test_written_paths_extracts_only_absolute_paths(tmp_path):
service = _make_service(tmp_path)
stdout = (
"/data/downloads/Artist/patreon/01.jpg\n"
"# /data/downloads/Artist/patreon/02.jpg\n" # archive skip — must be excluded
"/data/downloads/Artist/patreon/03.jpg\n"
"[patreon][info] some log line\n"
" /data/downloads/Artist/patreon/04.jpg \n" # surrounding whitespace
)
paths = service._written_paths(stdout)
names = [p.name for p in paths]
assert names == ["01.jpg", "03.jpg", "04.jpg"]
# --- _summarize_validation_failures ---
def test_summary_with_uniform_reason():
failures = [
{"reason": "missing JPEG EOI marker (FF D9)"} for _ in range(3)
]
msg = _summarize_validation_failures(failures)
assert "3 files" in msg
assert "EOI" in msg
def test_summary_with_mixed_reasons():
failures = [
{"reason": "missing JPEG EOI marker (FF D9)"},
{"reason": "missing JPEG EOI marker (FF D9)"},
{"reason": "zero-byte file"},
]
msg = _summarize_validation_failures(failures)
assert "3 files" in msg
assert "mixed" in msg
# --- validate_files=False respects opt-out ---
def test_validate_files_false_skips_quarantine(tmp_path):
service = _make_service(tmp_path, validate=False)
bad = Path(service.settings.download_path) / "Artist" / "patreon" / "01.jpg"
_write_jpeg(bad, truncated=True)
# download() is async + spawns subprocess. Cheaper test: confirm the
# _validate_files flag is wired and the helper isn't called when false.
# We don't run download() here; the constructor flag is the contract.
assert service._validate_files is False