"""Unit tests for PixivDownloader — no network, no DB. The HTTP layer is stubbed via the `session` seam (a fake session whose `.get` returns canned streaming bytes). Media items come from the real PixivClient.extract_media over the shared fixture page, so the downloader is exercised against the exact shapes the client produces. """ from __future__ import annotations import json from pathlib import Path import requests from backend.app.services.pixiv_client import MediaItem, PixivClient from backend.app.services.pixiv_downloader import PixivDownloader _FIXTURE = Path(__file__).parent / "fixtures" / "pixiv_user_illusts_page1.json" # 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, status_code: int = 200, headers=None): self._payload = payload self.status_code = status_code self.headers = headers or {} def raise_for_status(self): if self.status_code >= 400: raise requests.HTTPError(f"HTTP {self.status_code}") 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, headers=None): self.calls.append(url) return _FakeResponse(self.payloads.get(url, _PNG_BYTES)) def _client(): # Parsing only — extract_media for non-ugoira works never issues a request. return PixivClient("tok", session=_FakeSession()) def _post_and_media(work_id): page = json.loads(_FIXTURE.read_text()) client = _client() for work in page["illusts"]: if work["id"] == work_id: post = client._normalize(work) return post, client.extract_media(post, {}) raise AssertionError(f"no work {work_id} in fixture") def _post_only(work_id): """Normalized post WITHOUT media extraction — the write_post_record tests don't need media, and extracting an ugoira would hit the API seam.""" page = json.loads(_FIXTURE.read_text()) for work in page["illusts"]: if work["id"] == work_id: return _client()._normalize(work) raise AssertionError(f"no work {work_id} in fixture") def _downloader(tmp_path, session=None): # validate=False: the stub payload is PNG bytes regardless of the target # extension, so real validation would quarantine the .jpg cases. The # validate/quarantine plumbing is BaseNativeDownloader's, covered by the # Patreon downloader tests. return PixivDownloader( tmp_path, validate=False, session=session if session is not None else _FakeSession(), ) def test_download_post_writes_gallery_dl_layout(tmp_path): post, media = _post_and_media(111) dl = _downloader(tmp_path) outcomes = dl.download_post(post, media, "artist-a") assert [o.status for o in outcomes] == ["downloaded", "downloaded"] flat = tmp_path / "artist-a" / "pixiv" / "pixiv" p0 = flat / "111_Multi Page Adventure_00.png" p1 = flat / "111_Multi Page Adventure_01.png" assert p0.is_file() assert p1.is_file() # No per-post directory — pixiv's layout is flat (gallery-dl parity). assert {p.parent for p in (p0, p1)} == {flat} def test_download_post_writes_minimal_sidecar(tmp_path): post, media = _post_and_media(222) dl = _downloader(tmp_path) outcomes = dl.download_post(post, media, "artist-a") assert outcomes[0].status == "downloaded" sidecar = outcomes[0].path.with_suffix(".json") data = json.loads(sidecar.read_text()) # Post-first: image identity ONLY — the body lives in the post record. assert data == { "category": "pixiv", "id": "222", "source_url": media[0].url, } def test_download_post_skips_seen(tmp_path): post, media = _post_and_media(222) session = _FakeSession() dl = _downloader(tmp_path, session=session) outcomes = dl.download_post(post, media, "artist-a", is_seen=lambda m: True) assert [o.status for o in outcomes] == ["skipped_seen"] assert session.calls == [] def test_download_post_skips_on_disk(tmp_path): post, media = _post_and_media(222) flat = tmp_path / "artist-a" / "pixiv" / "pixiv" flat.mkdir(parents=True) existing = flat / "222_Single Piece_00.jpg" existing.write_bytes(b"already here") session = _FakeSession() dl = _downloader(tmp_path, session=session) outcomes = dl.download_post(post, media, "artist-a") assert [o.status for o in outcomes] == ["skipped_disk"] assert outcomes[0].path == existing assert session.calls == [] assert existing.read_bytes() == b"already here" def test_recapture_surfaces_on_disk_path_for_seen_media(tmp_path): post, media = _post_and_media(222) flat = tmp_path / "artist-a" / "pixiv" / "pixiv" flat.mkdir(parents=True) (flat / "222_Single Piece_00.jpg").write_bytes(b"kept") dl = _downloader(tmp_path) outcomes = dl.download_post( post, media, "artist-a", is_seen=lambda m: True, recapture=True, ) # On disk → surfaced with its path (relink channel); seen-but-missing # stays skipped_seen (recovery's job, not recapture's). assert outcomes[0].status == "skipped_disk" assert outcomes[0].path is not None def test_recapture_does_not_redownload_missing_seen_media(tmp_path): post, media = _post_and_media(222) session = _FakeSession() dl = _downloader(tmp_path, session=session) outcomes = dl.download_post( post, media, "artist-a", is_seen=lambda m: True, recapture=True, ) assert [o.status for o in outcomes] == ["skipped_seen"] assert session.calls == [] def test_download_post_honours_should_stop(tmp_path): post, media = _post_and_media(111) dl = _downloader(tmp_path) outcomes = dl.download_post(post, media, "artist-a", should_stop=lambda: True) assert outcomes == [] def test_filename_matches_gallery_dl_linux_restrict(tmp_path): # gallery-dl on Linux replaces ONLY "/" and deletes control chars — the # Windows-forbidden set (<>:"|?*) stays RAW in the on-disk name. Matching # that byte-for-byte is what lets the tier-2 disk-skip recognize # pre-cutover files; a stricter sanitizer would re-download them. post, _ = _post_and_media(222) weird = MediaItem( url="https://i.pximg.net/img-original/img/2026/06/18/12/30/00/222_p0.jpg", filename='222_What? A "Title"/Slash\x0900.jpg', # ? " stay, / → _, \t dropped kind="image", filehash=None, post_id="222", media_id="p0", ) dl = _downloader(tmp_path) outcomes = dl.download_post(post, [weird], "artist-a") assert outcomes[0].status == "downloaded" assert outcomes[0].path.name == '222_What? A "Title"_Slash00.jpg' def test_gdl_clean_filename_parity(): from backend.app.services.pixiv_downloader import gdl_clean_filename # Only "/" is replaced; the Windows-forbidden set is untouched. assert gdl_clean_filename('a<>:"|?*b.png') == 'a<>:"|?*b.png' assert gdl_clean_filename("a/b/c.png") == "a_b_c.png" # Control chars (newline, tab, DEL) are deleted, not replaced. assert gdl_clean_filename("a\nb\tc\x7fd.png") == "abcd.png" # Trailing dots/spaces are NOT stripped on Linux. assert gdl_clean_filename("title . .png") == "title . .png" def test_own_session_carries_app_referer(tmp_path): # Built session (no injection) must carry the app profile — i.pximg.net # 403s GETs without the app-api Referer. dl = PixivDownloader(tmp_path) assert dl.session.headers["Referer"] == "https://app-api.pixiv.net/" assert dl.session.headers["App-OS"] == "ios" def test_write_post_record_enriched(tmp_path): post = _post_only(111) dl = _downloader(tmp_path) outcome = dl.write_post_record(post, "artist-a") assert outcome.path == ( tmp_path / "artist-a" / "pixiv" / "pixiv" / "_post_111.json" ) assert outcome.title == "Multi Page Adventure" assert outcome.post_type == "illust" assert outcome.body_chars == len(post["attributes"]["content"]) data = json.loads(outcome.path.read_text()) assert data["category"] == "pixiv" assert data["id"] == "111" assert data["published_at"] == "2026-06-20T18:00:00+09:00" assert data["rating"] == "General" assert data["page_count"] == 2 assert data["total_bookmarks"] == 250 assert data["total_view"] == 1000 assert data["illust_ai_type"] == 1 assert data["series"] == {"id": 4242, "title": "Adventure Series"} assert data["tags"] == [ {"name": "オリジナル", "translated_name": "original"}, {"name": "女の子", "translated_name": "girl"}, ] assert data["user"] == {"id": 99, "account": "exartist", "name": "Example Artist"} # JP tags stay human-readable on disk (ensure_ascii=False). assert "オリジナル" in outcome.path.read_text() def test_write_post_record_rating_r18(tmp_path): post = _post_only(222) dl = _downloader(tmp_path) outcome = dl.write_post_record(post, "artist-a") data = json.loads(outcome.path.read_text()) assert data["rating"] == "R-18" assert data["is_bookmarked"] is True def test_write_post_record_includes_ugoira_frames(tmp_path): post = _post_only(333) frames = [{"file": "000000.jpg", "delay": 90}] post["_work"]["_ugoira_frames"] = frames dl = _downloader(tmp_path) outcome = dl.write_post_record(post, "artist-a") data = json.loads(outcome.path.read_text()) assert data["type"] == "ugoira" assert data["ugoira_frames"] == frames def test_write_post_record_fetches_ugoira_frames_when_absent(tmp_path): # The core writes the post record BEFORE extract_media, so frames aren't # memoized yet — the injected fetcher must populate them so the record keeps # the timings (regression: they were silently always empty). post = _post_only(333) assert "_ugoira_frames" not in post["_work"] frames = [{"file": "000000.jpg", "delay": 120}] calls = [] def fetcher(p): calls.append(p) p["_work"]["_ugoira_frames"] = frames dl = PixivDownloader( tmp_path, validate=False, session=_FakeSession(), ugoira_frames_fetcher=fetcher, ) outcome = dl.write_post_record(post, "artist-a") data = json.loads(outcome.path.read_text()) assert data["ugoira_frames"] == frames assert len(calls) == 1 def test_write_post_record_non_ugoira_does_not_call_fetcher(tmp_path): post = _post_only(111) # a plain multi-page illust calls = [] dl = PixivDownloader( tmp_path, validate=False, session=_FakeSession(), ugoira_frames_fetcher=lambda p: calls.append(p), ) dl.write_post_record(post, "artist-a") assert calls == [] def test_write_post_record_without_id(tmp_path): dl = _downloader(tmp_path) outcome = dl.write_post_record({"id": None, "attributes": {}}, "artist-a") assert outcome.path is None assert outcome.body_chars == 0