2765c464bd
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>
184 lines
6.0 KiB
Python
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
|