Files
FabledCurator/tests/test_pixiv_client.py
bvandeusen 0c4b8aef8c
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m32s
feat(artist): pixiv display-name at add-time + identity-by-source (#130 steps 2+3)
Final piece of the artist decoupling. (1) Identity-by-source: quick_add_source
resolves the artist by an existing (platform, url) Source first, so a re-add
reuses the artist even after it was renamed (its frozen slug no longer matches
the name) — a slug-based lookup would have duplicated it. (2) Pixiv naming: a new
pixiv source resolves the real display name via the app API (PixivClient
.resolve_display_name → /v1/user/detail) using the stored token, so the artist is
'Kurotsuchi Machi' not '12345678' — and its name-derived slug matches what a
native download produces, unifying them. Falls back to the numeric id when no
token/crypto. ExtensionService gains the crypto seam; the endpoint passes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-04 22:24:25 -04:00

420 lines
15 KiB
Python

"""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