"""PatreonClient tests — parsing only, no network. The fixture is loaded from disk and fed directly to the pure parsing methods (_transform / extract_media / _validate_response / parse_cursor_from_url); no live requests call is exercised here. """ import json from pathlib import Path import pytest import requests from backend.app.services import patreon_client as pc_mod from backend.app.services.patreon_client import ( MediaItem, PatreonAPIError, PatreonAuthError, PatreonClient, PatreonDriftError, _retry_after_seconds, parse_cursor_from_url, ) _FIXTURE = Path(__file__).parent / "fixtures" / "patreon_posts_page1.json" @pytest.fixture def response(): return json.loads(_FIXTURE.read_text()) @pytest.fixture def client(): # No cookies file → empty session, but we never make a request in tests. return PatreonClient(cookies_path=None) def _post_by_id(response, post_id): for post in response["data"]: if post["id"] == post_id: return post raise AssertionError(f"no post {post_id} in fixture") def test_transform_indexes_included(client, response): index = client._transform(response) assert index[("media", "9001")]["file_name"] == "gallery-one.jpg" assert index[("media", "9003")]["file_name"] == "bonus-pack.zip" assert index[("campaign", "5555")]["name"] == "Example Creator" # Unknown keys are absent, not errors. assert ("media", "0000") not in index def test_extract_gallery_dedups_image_large(client, response): index = client._transform(response) post = _post_by_id(response, "1001") items = client.extract_media(post, index) # Two gallery images; the image_large cover shares filehash aaaa... with # gallery image 9001 → collapses to one, leaving exactly 2 items. assert len(items) == 2 assert all(isinstance(m, MediaItem) for m in items) kinds = [m.kind for m in items] assert kinds == ["images", "images"] # image_large deduped away filenames = {m.filename for m in items} assert filenames == {"gallery-one.jpg", "gallery-two.jpg"} hashes = {m.filehash for m in items} assert hashes == {"a" * 32, "b" * 32} assert all(m.post_id == "1001" for m in items) def test_extract_attachment_postfile_same_file_collapses(client, response): index = client._transform(response) post = _post_by_id(response, "1002") items = client.extract_media(post, index) # The attachment (attachments_media 9003) and the post_file are the SAME # zip — both carry filehash c…. attachments resolve before postfile, so the # postfile collapses into the attachment by filehash → exactly one survives. # (postfile-as-a-kind is exercised separately by the video post test.) assert len(items) == 1 item = items[0] assert item.kind == "attachments" assert item.filename == "bonus-pack.zip" assert item.filehash == "c" * 32 def test_extract_inline_content_img(client, response): index = client._transform(response) post = _post_by_id(response, "1003") items = client.extract_media(post, index) assert len(items) == 1 item = items[0] assert item.kind == "content" assert item.filehash == "d" * 32 # & in the src must be unescaped to & . assert "&" not in item.url assert "token=x&v=2" in item.url def test_extract_video_postfile(client, response): index = client._transform(response) post = _post_by_id(response, "1004") items = client.extract_media(post, index) assert len(items) == 1 item = items[0] assert item.kind == "postfile" assert item.url.startswith("https://stream.mux.com/") assert item.filehash == "e" * 32 def test_parse_cursor_from_url_extracts_cursor(response): next_url = response["links"]["next"] assert parse_cursor_from_url(next_url) == "NEXTCURSOR123" def test_parse_cursor_from_url_none_when_absent(): assert parse_cursor_from_url("https://www.patreon.com/api/posts?sort=-x") is None assert parse_cursor_from_url(None) is None def test_iter_posts_yields_triples_and_paginates(client, response, monkeypatch): # Page 1 = the fixture (4 posts, links.next → NEXTCURSOR123); page 2 = one # post, no links.next → stop. Monkeypatch _fetch so no network is touched. page2 = { "data": [{"id": "1005", "type": "post", "attributes": {"title": "older"}}], "included": [], "links": {}, } def fake_fetch(campaign_id, cursor): return page2 if cursor == "NEXTCURSOR123" else response monkeypatch.setattr(client, "_fetch", fake_fetch) seen = list(client.iter_posts("5555")) # 4 posts on page 1 + 1 on page 2. assert [p["id"] for p, _idx, _cur in seen] == ["1001", "1002", "1003", "1004", "1005"] # page_cursor is the cursor that FETCHED the post's page. cursors = {p["id"]: cur for p, _idx, cur in seen} assert cursors["1001"] is None and cursors["1005"] == "NEXTCURSOR123" # included_index is the page's flattened resources, ready for extract_media. page1_index = next(idx for p, idx, _cur in seen if p["id"] == "1001") assert page1_index[("media", "9001")]["file_name"] == "gallery-one.jpg" def test_missing_data_raises_drift(client): with pytest.raises(PatreonDriftError): client._validate_response({"included": []}) def test_non_list_data_raises_drift(client): with pytest.raises(PatreonDriftError): client._validate_response({"data": {"id": "1"}}) def test_media_missing_file_name_falls_back_to_url_basename(client): # A gallery image with a valid URL but no file_name is NOT drift — Patreon # serves these (operator-flagged 2026-06-07, BlenderKnight post 73665615); # fall back to the URL basename like gallery-dl does. post = { "id": "2000", "type": "post", "attributes": {"title": "broken"}, "relationships": {"images": {"data": [{"id": "7777", "type": "media"}]}}, } index = { ("media", "7777"): { "download_url": "https://c10.patreonusercontent.com/4/orig/" + "f" * 32 + "/x.jpg" } } items = client.extract_media(post, index) assert len(items) == 1 assert items[0].filename == "x.jpg" assert items[0].kind == "images" def test_media_referenced_but_absent_raises_drift(client): post = { "id": "2001", "type": "post", "attributes": {"title": "dangling"}, "relationships": {"images": {"data": [{"id": "8888", "type": "media"}]}}, } with pytest.raises(PatreonDriftError): client.extract_media(post, {}) # -- HTTP status classification (plan #697 step 4) ------------------------- class _FakeResp: def __init__(self, status_code, *, json_data=None, raise_json=False, headers=None): self.status_code = status_code self._json_data = json_data self._raise_json = raise_json self.headers = headers or {} def json(self): if self._raise_json: raise ValueError("Expecting value: line 1 column 1") return self._json_data def _client_returning(monkeypatch, resp): client = PatreonClient(cookies_path=None) monkeypatch.setattr(client._session, "get", lambda *a, **k: resp) return client @pytest.mark.parametrize("status", [401, 403]) def test_fetch_auth_status_raises_auth_error(monkeypatch, status): client = _client_returning(monkeypatch, _FakeResp(status)) with pytest.raises(PatreonAuthError) as ei: client._fetch("5555", None) assert ei.value.status_code == status def test_fetch_non_json_body_is_auth_not_drift(monkeypatch): # An HTML login/challenge page (non-JSON) means the session expired — auth, # actionable as "rotate cookies", NOT API drift. client = _client_returning(monkeypatch, _FakeResp(200, raise_json=True)) with pytest.raises(PatreonAuthError): client._fetch("5555", None) @pytest.mark.parametrize("status", [404, 429, 500]) def test_fetch_other_status_raises_api_error_with_code(monkeypatch, status): client = _client_returning(monkeypatch, _FakeResp(status)) with pytest.raises(PatreonAPIError) as ei: client._fetch("5555", None) assert ei.value.status_code == status # Not auth — the ingester maps these by status, not to auth_error. assert not isinstance(ei.value, PatreonAuthError) def test_fetch_ok_returns_payload(monkeypatch): client = _client_returning(monkeypatch, _FakeResp(200, json_data={"data": []})) assert client._fetch("5555", None) == {"data": []} # -- verify_auth (credential probe; (True/False/None, message) contract) --- def test_verify_auth_ok_when_page_authenticates(monkeypatch): client = _client_returning(monkeypatch, _FakeResp(200, json_data={"data": []})) ok, msg = client.verify_auth("5555") assert ok is True assert "valid" in msg.lower() @pytest.mark.parametrize("status", [401, 403]) def test_verify_auth_false_when_rejected(monkeypatch, status): client = _client_returning(monkeypatch, _FakeResp(status)) ok, _msg = client.verify_auth("5555") assert ok is False def test_verify_auth_inconclusive_on_drift(monkeypatch): # 200 + JSON but missing top-level 'data' → drift → inconclusive, NOT a # credential verdict (our parser is stale, the cookie may be fine). client = _client_returning(monkeypatch, _FakeResp(200, json_data={"included": []})) ok, msg = client.verify_auth("5555") assert ok is None assert "api shape changed" in msg.lower() def test_verify_auth_inconclusive_on_network(monkeypatch): import requests client = PatreonClient(cookies_path=None) def _raise(*a, **k): raise requests.ConnectionError("boom") monkeypatch.setattr(client._session, "get", _raise) ok, _msg = client.verify_auth("5555") assert ok is None # -- 429 backoff + pacing (plan #703) -------------------------------------- def test_retry_after_seconds_honors_numeric_header(): assert _retry_after_seconds(_FakeResp(429, headers={"Retry-After": "7"}), 1) == 7.0 def test_retry_after_seconds_exponential_without_header(): resp = _FakeResp(429) assert _retry_after_seconds(resp, 1) == 2.0 assert _retry_after_seconds(resp, 2) == 4.0 assert _retry_after_seconds(resp, 3) == 8.0 def test_retry_after_seconds_caps(): assert _retry_after_seconds(_FakeResp(429), 10) == 30.0 assert _retry_after_seconds(_FakeResp(429, headers={"Retry-After": "999"}), 1) == 30.0 def _client_with_sequence(monkeypatch, responses): """A client whose session yields `responses` in order; time.sleep stubbed so backoff never really sleeps. Returns (client, slept_list).""" client = PatreonClient(cookies_path=None) it = iter(responses) monkeypatch.setattr(client._session, "get", lambda *a, **k: next(it)) slept: list[float] = [] monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s)) return client, slept def test_fetch_retries_then_succeeds_on_429(monkeypatch): client, slept = _client_with_sequence(monkeypatch, [ _FakeResp(429, headers={"Retry-After": "5"}), _FakeResp(200, json_data={"data": []}), ]) assert client._fetch("5555", None) == {"data": []} assert 5.0 in slept # backed off once before the retry def test_fetch_persistent_429_raises_after_retries(monkeypatch): client, _slept = _client_with_sequence(monkeypatch, [_FakeResp(429)] * 10) with pytest.raises(PatreonAPIError) as ei: client._fetch("5555", None) assert ei.value.status_code == 429 assert not isinstance(ei.value, PatreonAuthError) def test_fetch_persistent_429_surfaces_retry_after(monkeypatch): """plan #708 B1: a terminal 429 carries the server's raw Retry-After seconds (uncapped here — the cooldown clamps) so the platform cooldown matches it.""" client, _slept = _client_with_sequence( monkeypatch, [_FakeResp(429, headers={"Retry-After": "120"})] * 10, ) with pytest.raises(PatreonAPIError) as ei: client._fetch("5555", None) assert ei.value.status_code == 429 assert ei.value.retry_after == 120.0 def test_request_sleep_paces_before_fetch(monkeypatch): client = PatreonClient(cookies_path=None, request_sleep=1.5) monkeypatch.setattr( client._session, "get", lambda *a, **k: _FakeResp(200, json_data={"data": []}), ) slept: list[float] = [] monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s)) client._fetch("5555", None) assert slept == [1.5] # -- fetch_post_detail_content (best-effort full-body enrichment) ---------- def test_fetch_post_detail_content_returns_legacy_content(monkeypatch): """Old posts still carry the flat `content` HTML — returned as-is. The detail fetch uses the DEFAULT fieldset (no sparse `fields[post]`, which nulls the body) — #842.""" payload = {"data": {"id": "42", "attributes": {"content": "

full body

"}}} captured: dict = {} client = PatreonClient(cookies_path=None) def _get(url, params=None, timeout=None): captured["url"] = url captured["params"] = params return _FakeResp(200, json_data=payload) monkeypatch.setattr(client._session, "get", _get) assert client.fetch_post_detail_content("42") == "

full body

" assert captured["url"].endswith("/api/posts/42") # No sparse fieldset — that's what nulled the body on the live API. assert not captured["params"] or "fields[post]" not in captured["params"] def test_fetch_post_detail_content_converts_content_json_string(monkeypatch): """Current posts: `content` is null and the body lives in the `content_json_string` ProseMirror doc → converted to HTML (#842).""" doc = ( '{"type":"doc","content":[{"type":"paragraph","content":' '[{"type":"text","text":"Hello"}]}]}' ) payload = {"data": {"attributes": {"content": None, "content_json_string": doc}}} client = _client_returning(monkeypatch, _FakeResp(200, json_data=payload)) assert client.fetch_post_detail_content("42") == "

Hello

" def test_fetch_post_detail_content_empty_body_is_none(monkeypatch): payload = {"data": {"attributes": {"content": " ", "content_json_string": ""}}} client = _client_returning(monkeypatch, _FakeResp(200, json_data=payload)) assert client.fetch_post_detail_content("42") is None def test_fetch_post_detail_content_blank_id_skips_request(monkeypatch): calls: list = [] client = PatreonClient(cookies_path=None) monkeypatch.setattr( client._session, "get", lambda *a, **k: calls.append(1) or _FakeResp(200, json_data={}), ) assert client.fetch_post_detail_content("") is None assert calls == [] def test_fetch_post_detail_content_swallows_http_and_transport_errors(monkeypatch): # Non-200 → None (best-effort; a missing body must never fail the walk). client = _client_returning(monkeypatch, _FakeResp(500)) assert client.fetch_post_detail_content("42") is None # Non-JSON body (HTML challenge) → None, not a raise. client = _client_returning(monkeypatch, _FakeResp(200, raise_json=True)) assert client.fetch_post_detail_content("42") is None # Transport error → None. client = PatreonClient(cookies_path=None) def _boom(*a, **k): raise requests.ConnectionError("boom") monkeypatch.setattr(client._session, "get", _boom) assert client.fetch_post_detail_content("42") is None # -- post_record_key (media-less post seen-ledger gate) -------------------- def test_post_record_key_returns_synthetic_key_and_id(): assert PatreonClient.post_record_key({"id": "987"}) == ("post:987", "987") assert PatreonClient.post_record_key({"id": 987}) == ("post:987", "987") def test_post_record_key_none_without_id(): assert PatreonClient.post_record_key({}) is None assert PatreonClient.post_record_key({"id": None}) is None # -- post_is_gated (#874 tier-gated access filter) -------------------------- def test_post_is_gated_only_on_explicit_false(): # The blurred-preview case: account can't view the post → gate it. assert PatreonClient.post_is_gated( {"attributes": {"current_user_can_view": False}} ) is True def test_post_is_gated_viewable_and_missing_are_not_gated(): # Explicit True, a missing flag, an explicit None, and a missing attributes # block ALL count as viewable — gate only on an explicit False so an absent # field never over-filters accessible posts. assert PatreonClient.post_is_gated( {"attributes": {"current_user_can_view": True}} ) is False assert PatreonClient.post_is_gated({"attributes": {}}) is False assert PatreonClient.post_is_gated( {"attributes": {"current_user_can_view": None}} ) is False assert PatreonClient.post_is_gated({}) is False