feat: PatreonClient.iter_memberships — the roster seam (milestone 387 step C2)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 58s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m25s
Build images / build-ml (push) Successful in 2m45s
Build images / promote (push) Skipped

Built on C0's real capture (Scribe note #3886), not on API docs — gallery-dl
has no membership extractor and Patreon's public v2 API is the CREATOR surface
behind OAuth, so the rule-130 reference had to be a characterized response.

**The request is deliberately minimal, and that is a privacy decision.** The
browser's own include set pulls `latest_pledge.card`, and those card resources
come back carrying the ACCOUNT HOLDER'S EMAIL in `merchant_name`; `address` is
in there too. Copying the query string wholesale is the obvious move and would
have FC fetching payment PII it has no use for and can only mishandle. We ask
for `include=campaign,reward` and nothing else, and a test asserts on the
params actually sent so nobody widens it back.

**We do not send `filter[membership_type]`.** The browser sends the six buckets
its settings page displays, which excludes lapsed memberships — and a
DISAPPEARANCE is precisely the signal the roster exists to read. Filtering here
would manufacture the event C4 acts on.

Two corrections the capture forced, both now in code:

* **The filter vocabulary is not the status vocabulary.** I had read the six
  filter words off a screenshot and was about to write them into
  MEMBERSHIP_STATUS as the enum. The body shows `patron_status` carrying
  `former_patron` — absent from that filter — on a row the filter selected as
  `free_member`. So the map is taught exactly the two OBSERVED values, and
  `declined_patron` stays out despite looking obviously right: believing the
  filter is the mistake that was just caught.
* **Free membership is a boolean, not a status.** `has_paid_access` gains an
  `is_free_member` axis, because `active_patron` alone would report a free
  follower as a paying patron and C4 would never offer to clean it up. Honest
  limit, stated in the docstring: the capture has no ACTIVE free member, so it
  shows the separation is possible, not that it occurs.

C1's tripwire test did its job — it was written to fail the moment anyone
populated the status map, and updating it here IS the confirmation step, done
with the capture rather than ahead of it.

`_fetch`'s retry/backoff/auth-vs-drift/Retry-After logic is extracted to a
shared `_request` so the roster rides the same path rather than growing a
second copy — two copies would drift, and the half that drifted would be the
one that only runs daily. Every error message and log line renders
byte-identically for the posts path, so the existing tests pin the refactor.

Pagination is driven by `page[offset]` against `meta.pagination.total`, never
by `links`: the response's own `links.first` is built WITHOUT the `/api/`
prefix the request uses, so following it would hit the web page. An empty page
is terminal regardless of what the total claims, so a server reporting more
rows than it hands over cannot spin the walk forever.

Drift is stricter here than on the posts path, on purpose: a missing
`meta.pagination.total` raises rather than returning a short list, because a
truncated roster reads downstream as "you cancelled those" — the worst wrong
answer this feature can give.

`current_user_id()` is marked INFERRED, not characterized: C0 captured
/api/members, not /api/current_user, so it relies only on the JSON:API envelope
this API demonstrably uses elsewhere, and raises drift rather than returning
something plausible if that is wrong.

The fixture is derived from the real capture with every piece of account data
replaced (the raw capture stays gitignored). Six members, each earning its
place: a former patron with a null pledge, an active patron with no tier, an
annual cadence, a previous_pledge whose included resource has no
`relationships` key at all, and a reward priced in CAD beside a USD charge —
the trap that makes reading `reward.amount_cents` report a number the operator
was never charged. A leak check caught a free-membership-subscription id and
six real campaign launch timestamps before any of it was staged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-10 22:26:01 -04:00
co-authored by Claude Opus 5
parent 533a1ce674
commit afcde8e457
5 changed files with 1346 additions and 35 deletions
+287
View File
@@ -0,0 +1,287 @@
"""PatreonClient.iter_memberships — parsing only, no network (#387 C2).
The fixture is derived from a REAL capture of the operator's session (Scribe
note #3886) with every piece of account data replaced. It is small on purpose
and each member in it earns its place by covering something the parser has to
survive: a former patron with a null pledge, an active patron with no tier, an
annual cadence, a previous pledge whose included resource has no
`relationships` key at all, and a reward priced in a currency that is not the
patron's.
`_request` is stubbed rather than mocked at the socket: these tests are about
what the client does with a payload, and the HTTP path is already covered by
test_patreon_client.py.
"""
import json
from pathlib import Path
import pytest
from backend.app.services.patreon_client import (
Membership,
PatreonClient,
PatreonDriftError,
)
_FIXTURE = Path(__file__).parent / "fixtures" / "patreon_members_page1.json"
@pytest.fixture
def payload():
return json.loads(_FIXTURE.read_text())
@pytest.fixture
def client():
return PatreonClient(cookies_path=None)
def _serve(client, *pages):
"""Stub the shared request path to hand back canned pages in order."""
calls = []
def fake(url, params, *, what, scope):
calls.append((url, dict(params), what))
return pages[min(len(calls) - 1, len(pages) - 1)]
client._request = fake
return calls
# --- the request we send ---------------------------------------------------
def test_we_never_ask_for_the_card_or_address(client, payload):
"""THE privacy guard, and the reason it is first.
The browser's own include set pulls `latest_pledge.card`, and those card
resources come back carrying the ACCOUNT HOLDER'S EMAIL in `merchant_name`
(note #3886). Copying the browser's query string wholesale is the obvious
move and would have FC fetching payment PII it has no use for. This asserts
on the params actually sent, so it fails if anyone widens the include set
back toward the browser's.
"""
calls = _serve(client, payload)
list(client.iter_memberships(user_id="1"))
_url, params, _what = calls[0]
include = params["include"]
assert include == "campaign,reward"
for forbidden in ("card", "address", "payment_method", "latest_pledge"):
assert forbidden not in include
assert not any(k.startswith("fields[card") for k in params)
def test_we_do_not_send_the_membership_type_filter(client, payload):
"""The browser filters to the six buckets its settings page shows, which
excludes lapsed memberships. A DISAPPEARANCE is the signal the roster
exists to read, so filtering here would manufacture exactly the event C4
acts on."""
calls = _serve(client, payload)
list(client.iter_memberships(user_id="1"))
assert "filter[membership_type]" not in calls[0][1]
def test_the_user_filter_is_sent_when_given_and_omitted_when_not(client, payload):
calls = _serve(client, payload)
list(client.iter_memberships(user_id="248453"))
assert calls[0][1]["filter[user_id]"] == "248453"
calls2 = _serve(client, payload)
list(client.iter_memberships())
assert "filter[user_id]" not in calls2[0][1]
# --- what we parse out of it ----------------------------------------------
def test_every_member_becomes_a_membership(client, payload):
client._request = lambda *a, **k: payload
rows = list(client.iter_memberships(user_id="1"))
assert len(rows) == len(payload["data"])
assert all(isinstance(r, Membership) for r in rows)
# Campaign identity is the join key to Source; it must always be there.
assert all(r.campaign_id for r in rows)
def test_status_is_the_platforms_own_word_unmapped(client, payload):
client._request = lambda *a, **k: payload
rows = list(client.iter_memberships(user_id="1"))
statuses = {r.status for r in rows}
assert "active_patron" in statuses
assert "former_patron" in statuses
# Nothing normalised, nothing invented.
assert statuses <= {"active_patron", "former_patron"}
def test_a_former_patron_carries_a_null_amount_and_the_free_flag(client, payload):
client._request = lambda *a, **k: payload
former = next(r for r in list(client.iter_memberships(user_id="1"))
if r.status == "former_patron")
assert former.amount_cents is None
assert former.is_free_member is True
def test_an_active_patron_with_no_tier_is_not_an_error(client, payload):
"""`reward.data` is legitimately null. Absence is a fact about the
membership, not a parse failure."""
client._request = lambda *a, **k: payload
rows = list(client.iter_memberships(user_id="1"))
untiered = [r for r in rows if not r.tier_names]
assert untiered, "fixture should contain a member with no reward"
assert all(r.status for r in untiered)
def test_the_amount_comes_from_the_member_not_the_reward(client, payload):
"""The capture has rewards priced in CAD/DKK/EUR sitting on USD pledges.
Reading `reward.amount_cents` would report a number the operator has never
been charged, in a currency they do not pay in."""
client._request = lambda *a, **k: payload
rows = list(client.iter_memberships(user_id="1"))
by_campaign = {r.campaign_id: r for r in rows}
rewards = {r["id"]: r["attributes"] for r in payload["included"]
if r["type"] == "reward"}
for member in payload["data"]:
rel = (member["relationships"].get("reward") or {}).get("data")
if not rel:
continue
reward = rewards[rel["id"]]
if reward.get("currency") in (None, "USD"):
continue
# Skip the null-amount member: `None != 0` would pass without
# demonstrating anything. The case worth pinning is a REAL charge
# sitting beside a reward priced in another currency.
if member["attributes"]["pledge_amount_cents"] is None:
continue
got = by_campaign[member["relationships"]["campaign"]["data"]["id"]]
assert got.amount_cents == member["attributes"]["pledge_amount_cents"]
assert got.amount_cents != reward["amount_cents"]
assert got.currency == member["attributes"]["currency"]
break
else:
pytest.fail("fixture should contain a non-USD reward")
def test_details_carry_the_raw_attributes_but_not_the_whole_page(client, payload):
"""`details` exists so a later question needs no second authenticated
round-trip — but scoped to the member and its campaign, never the raw page,
because that is where the card and address resources live."""
client._request = lambda *a, **k: payload
row = next(iter(client.iter_memberships(user_id="1")))
assert set(row.details) == {"member", "campaign"}
assert "patron_status" in row.details["member"]
assert json.dumps(row.details).count("@") == 0
# --- pagination ------------------------------------------------------------
def test_pagination_walks_offsets_until_the_total_is_reached(client, payload):
half = len(payload["data"]) // 2
page1 = {**payload, "data": payload["data"][:half],
"meta": {"pagination": {"total": len(payload["data"])}}}
page2 = {**payload, "data": payload["data"][half:],
"meta": {"pagination": {"total": len(payload["data"])}}}
calls = []
def fake(url, params, *, what, scope):
calls.append(dict(params))
return page1 if len(calls) == 1 else page2
client._request = fake
rows = list(client.iter_memberships(user_id="1"))
assert len(rows) == len(payload["data"])
assert [c["page[offset]"] for c in calls] == ["0", str(half)]
def test_an_empty_page_stops_the_walk_even_if_the_total_disagrees(client):
"""A server that reports more rows than it hands over must not spin us
forever. The empty page is terminal regardless of the total."""
page = {"data": [], "included": [], "meta": {"pagination": {"total": 999}}}
client._request = lambda *a, **k: page
assert list(client.iter_memberships(user_id="1")) == []
def test_an_empty_roster_is_not_an_error(client):
page = {"data": [], "included": [], "meta": {"pagination": {"total": 0}}}
client._request = lambda *a, **k: page
assert list(client.iter_memberships(user_id="1")) == []
def test_we_never_follow_links_first(client, payload):
"""The response's own `links.first` is built WITHOUT the `/api/` prefix the
request uses, so following it would hit the web page. Pagination is driven
by page[offset] instead — asserted here because the bug it prevents looks
like an auth failure, not a URL mistake."""
assert "/api/" not in payload["links"]["first"]
calls = _serve(client, payload)
list(client.iter_memberships(user_id="1"))
assert all(url.startswith("https://www.patreon.com/api/") for url, _p, _w in calls)
# --- drift -----------------------------------------------------------------
def test_a_missing_total_is_drift_not_an_empty_roster(client, payload):
"""The distinction that matters most here. An empty roster reads to C4 as
'you cancelled everything', so a response we cannot verify as COMPLETE must
raise rather than return a short list."""
broken = {**payload, "meta": {}}
client._request = lambda *a, **k: broken
with pytest.raises(PatreonDriftError, match="pagination"):
list(client.iter_memberships(user_id="1"))
def test_a_missing_data_list_is_drift(client, payload):
client._request = lambda *a, **k: {"meta": {"pagination": {"total": 0}}}
with pytest.raises(PatreonDriftError):
list(client.iter_memberships(user_id="1"))
def test_a_member_with_no_campaign_is_drift(client, payload):
mangled = json.loads(json.dumps(payload))
mangled["data"][0]["relationships"]["campaign"] = {"data": None}
client._request = lambda *a, **k: mangled
with pytest.raises(PatreonDriftError, match="campaign"):
list(client.iter_memberships(user_id="1"))
def test_a_member_with_no_patron_status_is_drift(client, payload):
mangled = json.loads(json.dumps(payload))
del mangled["data"][0]["attributes"]["patron_status"]
client._request = lambda *a, **k: mangled
with pytest.raises(PatreonDriftError, match="patron_status"):
list(client.iter_memberships(user_id="1"))
# --- current_user_id -------------------------------------------------------
def test_current_user_id_reads_the_json_api_envelope(client):
client._request = lambda *a, **k: {"data": {"id": "248453", "type": "user"}}
assert client.current_user_id() == "248453"
def test_current_user_id_raises_drift_rather_than_guessing(client):
"""This endpoint is INFERRED, not characterized (note #3886). If the
inference is wrong it must fail loudly — a confidently wrong user id would
scope the roster to somebody else and return an empty, believable list."""
client._request = lambda *a, **k: {"data": []}
with pytest.raises(PatreonDriftError, match="current_user"):
client.current_user_id()
# --- the seam itself -------------------------------------------------------
def test_the_seam_is_probed_not_required():
"""Milestone 387's whole seam design, and the reason Discord needs no
'unsupported' branch: a client without the method is simply a client the
roster never asks. Mirrors ingest_core's `getattr(client, 'post_is_gated')`.
"""
class ClientWithoutIt:
pass
assert getattr(ClientWithoutIt(), "iter_memberships", None) is None
assert getattr(PatreonClient(cookies_path=None), "iter_memberships", None)