"""Unit tests for PatreonDownloader (build step 2b). No network, no real subprocess. The HTTP layer is stubbed via the `session` seam (a fake session whose `.get` returns a fake streaming response) and yt-dlp via monkeypatching `_run_ytdlp`. PURE module → no DB, so unmarked (fast lane). """ from __future__ import annotations from pathlib import Path import pytest from backend.app.services.patreon_client import MediaItem from backend.app.services.patreon_downloader import ( MediaOutcome, PatreonDownloader, _sanitize, ) from backend.app.utils.sidecar import find_sidecar # Minimal valid PNG so the file_validator passes on the happy path. _PNG_HEAD = b"\x89PNG\r\n\x1a\n" _PNG_TAIL = b"\x00\x00\x00\x00IEND\xaeB`\x82" _PNG_BYTES = _PNG_HEAD + (b"\x00" * 16) + _PNG_TAIL class _FakeResponse: def __init__(self, payload: bytes): self._payload = payload def raise_for_status(self): return None def iter_content(self, chunk_size=65536): for i in range(0, len(self._payload), chunk_size): yield self._payload[i : i + chunk_size] class _FakeSession: """Records GETs and returns canned bytes (per-URL, default _PNG_BYTES).""" def __init__(self, payloads: dict[str, bytes] | None = None): self.payloads = payloads or {} self.calls: list[str] = [] def get(self, url, stream=False, timeout=None): self.calls.append(url) return _FakeResponse(self.payloads.get(url, _PNG_BYTES)) def _post(post_id="1001", title="My Post Title", published="2026-05-01T12:00:00Z"): return { "id": post_id, "attributes": { "title": title, "content": "
body
", "published_at": published, "url": f"https://www.patreon.com/posts/{post_id}", }, } def _img(name, post_id="1001", url=None): return MediaItem( url=url or f"https://cdn.patreon.com/{name}", filename=name, kind="images", filehash=None, post_id=post_id, ) def _downloader(tmp_path: Path, session=None, validate=True): return PatreonDownloader( images_root=tmp_path, cookies_path=None, validate=validate, session=session or _FakeSession(), ) def test_path_layout_two_images(tmp_path): dl = _downloader(tmp_path) items = [_img("media1.png"), _img("media2.png")] outcomes = dl.download_post(_post(), items, "artist-x") post_dir = tmp_path / "artist-x" / "patreon" / "2026-05-01_1001_My Post Title" p1 = post_dir / "01_media1.png" p2 = post_dir / "02_media2.png" assert p1.is_file() assert p2.is_file() assert [o.status for o in outcomes] == ["downloaded", "downloaded"] assert outcomes[0].path == p1 assert p1.read_bytes() == _PNG_BYTES def test_dir_sanitized(tmp_path): dl = _downloader(tmp_path) post = _post(title='bad/name:with*chars?') dl.download_post(post, [_img("a.png")], "artist-x") patreon_dir = tmp_path / "artist-x" / "patreon" children = list(patreon_dir.iterdir()) assert len(children) == 1 name = children[0].name assert "/" not in name assert not any(c in name for c in '<>:"\\|?*') def test_no_date_prefix_when_unparseable(tmp_path): dl = _downloader(tmp_path) post = _post(published="not-a-date") dl.download_post(post, [_img("a.png")], "artist-x") patreon_dir = tmp_path / "artist-x" / "patreon" children = [c.name for c in patreon_dir.iterdir()] assert children == ["1001_My Post Title"] def test_sidecar_written_and_findable(tmp_path): dl = _downloader(tmp_path) outcomes = dl.download_post(_post(), [_img("media1.png")], "artist-x") media_path = outcomes[0].path sidecar = find_sidecar(media_path) assert sidecar is not None assert sidecar.name == "01_media1.json" import json data = json.loads(sidecar.read_text()) assert data["category"] == "patreon" assert data["id"] == "1001" assert data["title"] == "My Post Title" assert data["url"] == "https://www.patreon.com/posts/1001" def test_skip_seen(tmp_path): session = _FakeSession() dl = _downloader(tmp_path, session=session) outcomes = dl.download_post( _post(), [_img("media1.png")], "artist-x", is_seen=lambda m: True ) assert outcomes[0].status == "skipped_seen" assert outcomes[0].path is None assert session.calls == [] # No file written anywhere. assert not (tmp_path / "artist-x").exists() def test_skip_disk(tmp_path): dl = _downloader(tmp_path) post_dir = tmp_path / "artist-x" / "patreon" / "2026-05-01_1001_My Post Title" post_dir.mkdir(parents=True) existing = post_dir / "01_media1.png" existing.write_bytes(b"already here") outcomes = dl.download_post(_post(), [_img("media1.png")], "artist-x") assert outcomes[0].status == "skipped_disk" assert outcomes[0].path == existing assert existing.read_bytes() == b"already here" # untouched def test_video_routes_to_ytdlp(tmp_path, monkeypatch): session = _FakeSession() dl = _downloader(tmp_path, session=session) captured = {} def fake_ytdlp(url, dest, headers): captured["url"] = url captured["headers"] = headers out = Path(dest).with_suffix(".mp4") out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(b"video-bytes") return out monkeypatch.setattr(dl, "_run_ytdlp", fake_ytdlp) video = MediaItem( url="https://stream.mux.com/abc123.m3u8", filename="clip.mp4", kind="postfile", filehash=None, post_id="1001", ) outcomes = dl.download_post(_post(), [video], "artist-x") assert captured["url"] == "https://stream.mux.com/abc123.m3u8" assert captured["headers"]["Referer"] == "https://www.patreon.com/" assert captured["headers"]["Origin"] == "https://www.patreon.com" assert session.calls == [] # NOT the plain-GET path assert outcomes[0].status == "downloaded" assert outcomes[0].path.read_bytes() == b"video-bytes" # Sidecar written next to the actual (remuxed) output. assert find_sidecar(outcomes[0].path) is not None def test_one_failure_isolated(tmp_path): class _BoomSession(_FakeSession): def get(self, url, stream=False, timeout=None): self.calls.append(url) if "bad" in url: raise RuntimeError("network boom") return _FakeResponse(_PNG_BYTES) dl = _downloader(tmp_path, session=_BoomSession()) items = [ _img("good.png", url="https://cdn.patreon.com/good.png"), _img("bad.png", url="https://cdn.patreon.com/bad.png"), ] outcomes = dl.download_post(_post(), items, "artist-x") assert outcomes[0].status == "downloaded" assert outcomes[1].status == "error" assert "boom" in outcomes[1].error def test_validation_quarantines_corrupt(tmp_path): # A .png whose bytes are not a valid PNG → validator fails → error + # quarantine move; with validate=False it would pass. bad = b"not a real png" session = _FakeSession({"https://cdn.patreon.com/corrupt.png": bad}) dl = _downloader(tmp_path, session=session, validate=True) item = _img("corrupt.png", url="https://cdn.patreon.com/corrupt.png") outcomes = dl.download_post(_post(), [item], "artist-x") assert outcomes[0].status == "error" post_dir = tmp_path / "artist-x" / "patreon" / "2026-05-01_1001_My Post Title" assert not (post_dir / "01_corrupt.png").exists() quarantine = tmp_path / "_quarantine" / "artist-x" / "patreon" assert quarantine.is_dir() assert any(quarantine.iterdir()) def test_validation_off_keeps_bytes(tmp_path): bad = b"not a real png" session = _FakeSession({"https://cdn.patreon.com/x.png": bad}) dl = _downloader(tmp_path, session=session, validate=False) item = _img("x.png", url="https://cdn.patreon.com/x.png") outcomes = dl.download_post(_post(), [item], "artist-x") assert outcomes[0].status == "downloaded" assert outcomes[0].path.read_bytes() == bad def test_sanitize_helper(): assert _sanitize('a/b') == "a_b" assert _sanitize('x<>:"|?*y') == "x_______y" assert _sanitize("trail. ") == "trail" assert _sanitize("") == "_" def test_media_outcome_shape(): o = MediaOutcome(media=None, status="downloaded", path=Path("/tmp/x"), error=None) assert o.status == "downloaded" assert o.path == Path("/tmp/x")