"""PixivClient tests — parsing + iteration against canned pages, no network. The fixture mirrors a real `/v1/user/illusts` page (multi-page work, single page, ugoira, sanity-limited placeholder, deleted-author ghost). HTTP is stubbed at the requests-session seam (oauth POST + API GET), so the exact gallery-dl-parity request profile — headers, oauth form, pagination params — is asserted rather than assumed. """ import json from pathlib import Path import pytest from backend.app.services.pixiv_client import ( PIXIV_APP_HEADERS, MediaItem, PixivAPIError, PixivAuthError, PixivClient, PixivDriftError, rating_label, user_id_from_url, ) _FIXTURE = Path(__file__).parent / "fixtures" / "pixiv_user_illusts_page1.json" class FakeResponse: def __init__(self, status_code=200, json_data=None, headers=None): self.status_code = status_code self._json = json_data self.headers = headers or {} def json(self): if self._json is None: raise ValueError("no JSON") return self._json class FakeSession: """Minimal requests.Session stand-in: canned responses per (method, url fragment), recording every call for profile assertions.""" def __init__(self): self.headers = dict(PIXIV_APP_HEADERS) self.responses = [] self.calls = [] def queue(self, response): self.responses.append(response) return self def _next(self): if not self.responses: raise AssertionError("FakeSession ran out of queued responses") return self.responses.pop(0) def post(self, url, data=None, headers=None, timeout=None): self.calls.append(("POST", url, data, headers)) return self._next() def get(self, url, params=None, timeout=None, headers=None): self.calls.append(("GET", url, params, headers)) return self._next() def _oauth_ok(): return FakeResponse(200, { "response": { "access_token": "acc-token", "expires_in": 3600, "user": {"id": "77", "account": "operator", "name": "Op"}, } }) @pytest.fixture def page1(): return json.loads(_FIXTURE.read_text()) @pytest.fixture def client(): # Never issues a request in pure-parsing tests. return PixivClient("refresh-tok", session=FakeSession()) def _post_for(client, page1, work_id): for work in page1["illusts"]: if work["id"] == work_id: return client._normalize(work) raise AssertionError(f"no work {work_id} in fixture") # -- URL → user id ---------------------------------------------------------- def test_user_id_from_url_variants(): assert user_id_from_url("https://www.pixiv.net/users/12345678") == "12345678" assert user_id_from_url("https://www.pixiv.net/en/users/42") == "42" assert user_id_from_url("https://pixiv.net/users/7/artworks") == "7" assert user_id_from_url("https://www.pixiv.net/member.php?id=99") == "99" def test_user_id_from_url_rejects_non_matches(): assert user_id_from_url("https://www.pixiv.net/artworks/111") is None assert user_id_from_url("https://www.pixiv.net/users/notdigits") is None assert user_id_from_url("https://example.com/users/5") is None assert user_id_from_url("") is None # -- normalization ------------------------------------------------------------ def test_normalize_maps_attributes(client, page1): post = _post_for(client, page1, 111) attrs = post["attributes"] assert post["id"] == 111 assert attrs["title"] == "Multi Page Adventure" assert "Two-page set." in attrs["content"] assert attrs["published_at"] == "2026-06-20T18:00:00+09:00" assert attrs["post_type"] == "illust" assert post["_work"]["total_bookmarks"] == 250 def test_post_record_key_and_meta(client, page1): post = _post_for(client, page1, 222) assert client.post_record_key(post) == ("post:222", "222") meta = client.post_meta(post) assert meta["title"] == "Single Piece" assert meta["date"] == "2026-06-18T12:30:00+09:00" assert client.post_record_key({"id": None}) is None # -- gating ------------------------------------------------------------------- def test_post_is_gated_limit_placeholder(client, page1): assert client.post_is_gated(_post_for(client, page1, 444)) is True def test_post_is_gated_deleted_author(client, page1): assert client.post_is_gated(_post_for(client, page1, 555)) is True def test_post_is_gated_normal_works(client, page1): assert client.post_is_gated(_post_for(client, page1, 111)) is False assert client.post_is_gated(_post_for(client, page1, 222)) is False # -- extract_media -------------------------------------------------------------- def test_extract_media_multi_page(client, page1): items = client.extract_media(_post_for(client, page1, 111), {}) assert len(items) == 2 assert all(isinstance(m, MediaItem) for m in items) assert [m.media_id for m in items] == ["p0", "p1"] assert items[0].url.endswith("/111_p0.png") assert items[1].url.endswith("/111_p1.png") # gallery-dl filename parity: {id}_{title[:50]}_{num:>02}.{extension} assert items[0].filename == "111_Multi Page Adventure_00.png" assert items[1].filename == "111_Multi Page Adventure_01.png" assert all(m.post_id == "111" for m in items) def test_extract_media_single_page(client, page1): items = client.extract_media(_post_for(client, page1, 222), {}) assert len(items) == 1 assert items[0].media_id == "p0" assert items[0].url.endswith("/222_p0.jpg") assert items[0].filename == "222_Single Piece_00.jpg" def test_extract_media_gated_yields_nothing(client, page1): assert client.extract_media(_post_for(client, page1, 444), {}) == [] assert client.extract_media(_post_for(client, page1, 555), {}) == [] def test_extract_media_ugoira_zip_swap(client, page1, monkeypatch): frames = [{"file": "000000.jpg", "delay": 90}, {"file": "000001.jpg", "delay": 90}] def fake_call(endpoint, params): assert endpoint == "/v1/ugoira/metadata" assert params == {"illust_id": "333"} return {"ugoira_metadata": { "zip_urls": {"medium": ( "https://i.pximg.net/img-zip-ugoira/img/2026/06/15/09/00/00/" "333_ugoira600x600.zip" )}, "frames": frames, }} monkeypatch.setattr(client, "_call", fake_call) post = _post_for(client, page1, 333) items = client.extract_media(post, {}) assert len(items) == 1 assert items[0].media_id == "ugoira" assert items[0].kind == "ugoira" assert items[0].url.endswith("333_ugoira1920x1080.zip") assert items[0].filename == "333_Wiggle Loop_00.zip" # Frame delays memoized for the post record (future video conversion). assert post["_work"]["_ugoira_frames"] == frames def test_fetch_ugoira_frames_memoizes_and_shares_one_call(client, page1, monkeypatch): # fetch_ugoira_frames (called by write_post_record, which the core runs # BEFORE extract_media) populates frames; extract_media then REUSES the # memoized metadata — exactly one /v1/ugoira/metadata call total. frames = [{"file": "000000.jpg", "delay": 90}] calls = [] def fake_call(endpoint, params): calls.append(endpoint) return {"ugoira_metadata": { "zip_urls": {"medium": ( "https://i.pximg.net/img-zip-ugoira/x/333_ugoira600x600.zip" )}, "frames": frames, }} monkeypatch.setattr(client, "_call", fake_call) post = _post_for(client, page1, 333) client.fetch_ugoira_frames(post) assert post["_work"]["_ugoira_frames"] == frames items = client.extract_media(post, {}) # reuses memoized meta assert items[0].media_id == "ugoira" assert calls == ["/v1/ugoira/metadata"] # ONE fetch, not two def test_fetch_ugoira_frames_noop_for_non_ugoira(client, page1, monkeypatch): monkeypatch.setattr(client, "_call", lambda e, p: pytest.fail("should not fetch")) post = _post_for(client, page1, 222) # a single-page illust client.fetch_ugoira_frames(post) assert "_ugoira_frames" not in post["_work"] def test_extract_media_ugoira_metadata_failure_downgrades( client, page1, monkeypatch ): def fake_call(endpoint, params): raise PixivAPIError("boom", status_code=500) monkeypatch.setattr(client, "_call", fake_call) assert client.extract_media(_post_for(client, page1, 333), {}) == [] def test_extract_media_ugoira_auth_failure_stays_loud( client, page1, monkeypatch ): def fake_call(endpoint, params): raise PixivAuthError("token dead", status_code=400) monkeypatch.setattr(client, "_call", fake_call) with pytest.raises(PixivAuthError): client.extract_media(_post_for(client, page1, 333), {}) # -- iteration ------------------------------------------------------------------ def test_iter_posts_paginates_and_carries_cursor(page1, monkeypatch): client = PixivClient("refresh-tok", session=FakeSession()) page2 = { "illusts": [dict(page1["illusts"][1], id=666, title="Older")], "next_url": None, } seen_params = [] def fake_call(endpoint, params): assert endpoint == "/v1/user/illusts" seen_params.append(dict(params)) return page1 if len(seen_params) == 1 else page2 monkeypatch.setattr(client, "_call", fake_call) rows = list(client.iter_posts("99")) assert seen_params[0] == {"user_id": "99"} # Page 2 params come verbatim from next_url's query string. assert seen_params[1] == {"user_id": "99", "offset": "30"} # Page-1 posts carry cursor None; page-2 posts carry the fetching cursor. assert [c for _, _, c in rows[:5]] == [None] * 5 assert rows[5][2] == "user_id=99&offset=30" assert rows[5][0]["id"] == 666 def test_iter_posts_resumes_from_cursor(page1, monkeypatch): client = PixivClient("refresh-tok", session=FakeSession()) captured = {} def fake_call(endpoint, params): captured["params"] = dict(params) return {"illusts": [], "next_url": None} monkeypatch.setattr(client, "_call", fake_call) list(client.iter_posts("99", cursor="user_id=99&offset=60")) assert captured["params"] == {"user_id": "99", "offset": "60"} def test_iter_posts_rejects_non_numeric_campaign(): client = PixivClient("refresh-tok", session=FakeSession()) with pytest.raises(PixivDriftError): next(client.iter_posts("https://www.pixiv.net/users/99")) def test_iter_posts_drift_on_missing_illusts(monkeypatch): client = PixivClient("refresh-tok", session=FakeSession()) monkeypatch.setattr(client, "_call", lambda e, p: {"error": None, "body": 1}) with pytest.raises(PixivDriftError): next(client.iter_posts("99")) # -- auth ------------------------------------------------------------------------ def test_login_sends_gallery_dl_profile_and_sets_bearer(): session = FakeSession().queue(_oauth_ok()) client = PixivClient("refresh-tok", session=session) client._login() method, url, data, headers = session.calls[0] assert method == "POST" assert url == "https://oauth.secure.pixiv.net/auth/token" assert data["grant_type"] == "refresh_token" assert data["refresh_token"] == "refresh-tok" assert data["client_id"] == "MOBrBDS8blbauoSck0ZfDbtuzpyT" assert data["get_secure_url"] == "1" # X-Client-Time/-Hash pair: ISO + literal +00:00, md5 hex digest. assert headers["X-Client-Time"].endswith("+00:00") assert len(headers["X-Client-Hash"]) == 32 assert session.headers["Authorization"] == "Bearer acc-token" # Token is fresh → a second login is a no-op (no extra POST). client._login() assert len(session.calls) == 1 def test_login_maps_rejection_to_auth_error(): session = FakeSession().queue(FakeResponse(400, {"has_error": True})) client = PixivClient("refresh-tok", session=session) with pytest.raises(PixivAuthError): client._login() def test_login_without_token_is_auth_error(): client = PixivClient(None, session=FakeSession()) with pytest.raises(PixivAuthError): client._login() def test_call_maps_rate_limit_message(): session = FakeSession().queue(_oauth_ok()).queue(FakeResponse(403, { "error": {"message": "Rate Limit", "user_message": ""}, })) client = PixivClient("refresh-tok", session=session) with pytest.raises(PixivAPIError) as exc_info: client._call("/v1/user/illusts", {"user_id": "1"}) err = exc_info.value assert not isinstance(err, PixivAuthError) assert err.status_code == 429 assert err.retry_after == 300.0 def test_call_maps_403_to_auth_error(): session = FakeSession().queue(_oauth_ok()).queue(FakeResponse(403, { "error": {"message": "invalid access token", "user_message": ""}, })) client = PixivClient("refresh-tok", session=session) with pytest.raises(PixivAuthError): client._call("/v1/user/illusts", {"user_id": "1"}) def test_call_maps_404_status(): session = FakeSession().queue(_oauth_ok()).queue(FakeResponse(404, { "error": {"message": "Not Found", "user_message": ""}, })) client = PixivClient("refresh-tok", session=session) with pytest.raises(PixivAPIError) as exc_info: client._call("/v1/user/illusts", {"user_id": "1"}) assert exc_info.value.status_code == 404 def test_verify_auth_reports_account(): session = FakeSession().queue(_oauth_ok()) client = PixivClient("refresh-tok", session=session) ok, message = client.verify_auth() assert ok is True assert "operator" in message def test_verify_auth_bad_token(): session = FakeSession().queue(FakeResponse(400, {"has_error": True})) client = PixivClient("bad", session=session) ok, message = client.verify_auth() assert ok is False assert "rotate" in message.lower() or "rejected" in message.lower() def test_resolve_display_name(client, monkeypatch): monkeypatch.setattr( client, "_call", lambda e, p: {"user": {"name": "Kurotsuchi Machi", "id": p["user_id"]}}, ) assert client.resolve_display_name("99") == "Kurotsuchi Machi" def test_resolve_display_name_none_on_failure(client, monkeypatch): def boom(endpoint, params): raise PixivAPIError("nope", status_code=404) monkeypatch.setattr(client, "_call", boom) assert client.resolve_display_name("99") is None # Empty/whitespace name → None (caller falls back to the id). monkeypatch.setattr(client, "_call", lambda e, p: {"user": {"name": " "}}) assert client.resolve_display_name("99") is None # -- rating ------------------------------------------------------------------------ def test_rating_label(): assert rating_label(0) == "General" assert rating_label(1) == "R-18" assert rating_label(2) == "R-18G" assert rating_label(None) is None assert rating_label(True) is None assert rating_label(9) is None