From afcde8e4579f168ddc009c6285e4af156a99b9d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 10 Sep 2026 22:26:01 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20PatreonClient.iter=5Fmemberships=20?= =?UTF-8?q?=E2=80=94=20the=20roster=20seam=20(milestone=20387=20step=20C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 --- backend/app/services/membership_roster.py | 52 +- backend/app/services/patreon_client.py | 276 ++++++++- tests/fixtures/patreon_members_page1.json | 723 ++++++++++++++++++++++ tests/test_membership_roster.py | 43 +- tests/test_patreon_memberships.py | 287 +++++++++ 5 files changed, 1346 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/patreon_members_page1.json create mode 100644 tests/test_patreon_memberships.py diff --git a/backend/app/services/membership_roster.py b/backend/app/services/membership_roster.py index b05bd91..53dce25 100644 --- a/backend/app/services/membership_roster.py +++ b/backend/app/services/membership_roster.py @@ -38,30 +38,62 @@ log = logging.getLogger(__name__) # Platform word -> whether the account currently has paid access. # -# EMPTY ON PURPOSE. Every entry here must come from a characterised response -# (step C0), not from what the API docs or a plausible guess suggest — that is -# the whole point of project rule 130, and inventing `active_patron` before -# seeing it in a real payload is exactly the failure it names. Populate per -# platform as each is characterised. +# Every entry here must come from a CHARACTERISED response, never from API docs +# or a plausible guess — project rule 130, and inventing a status before seeing +# it in a real payload is exactly the failure it names. +# +# patreon: from a live capture of the operator's own session, 2026-09-10 +# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and +# only those two are here. +# +# `declined_patron` is deliberately ABSENT even though it looks obviously +# right. It appears in the request's `filter[membership_type]`, and the capture +# proved that filter is NOT the same vocabulary as the attribute — a row +# selected by the filter as `free_member` came back with +# `patron_status: former_patron`, a word the filter does not contain. Reading +# the filter as an enum is the specific mistake the capture caught; adding +# `declined_patron` on the strength of it would be repeating that mistake one +# step later. # # Unknown words are NOT an error: an unrecognised status means the roster # records evidence it cannot yet interpret, which is a better state than # dropping the row or asserting a meaning for it. -MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {} +MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = { + "patreon": { + "active_patron": True, + "former_patron": False, + }, +} -def has_paid_access(platform: str, status: str | None) -> bool | None: - """Does this status mean the account currently pays for access? +def has_paid_access( + platform: str, status: str | None, *, is_free_member: bool = False, +) -> bool | None: + """Does this membership mean the account currently PAYS for access? Returns None for a status this code has not been taught, which callers must treat as "unknown" rather than as False. The difference matters: False says the operator has lost access, and asserting that from an unrecognised word would tell them to cancel a source they are still paying for. + + `is_free_member` is a second axis, not a status, and that is Patreon's + design rather than ours: the capture shows a free follow expressed as a + boolean alongside `patron_status`, so a "current" membership can still be + one nobody is paying for. Taking status alone would report a free follower + as a paying patron, and C4 would then never offer to clean it up. + + (Honest limit: the capture contains no ACTIVE free member, so it cannot + demonstrate the two axes coming apart. The separation is what the payload's + shape says; the sample only shows it is possible, not that it happens.) """ if status is None: return None - entry = MEMBERSHIP_STATUS.get(platform, {}) - return entry.get(status) + known = MEMBERSHIP_STATUS.get(platform, {}).get(status) + if known is None: + return None + if not known: + return False + return not is_free_member async def touch_membership( diff --git a/backend/app/services/patreon_client.py b/backend/app/services/patreon_client.py index c9cd6a1..7d291d9 100644 --- a/backend/app/services/patreon_client.py +++ b/backend/app/services/patreon_client.py @@ -14,6 +14,18 @@ the later step can drive it: - extract_media(post, included_index) → list[MediaItem] - parse_cursor_from_url(url) → cursor +Milestone 387 added a SECOND read path on the same session: the membership +roster — what the ACCOUNT subscribes to, as opposed to what one creator has +posted. + - iter_memberships(user_id) → Iterator[Membership] + - current_user_id() → str + +It is an OPTIONAL seam by construction, probed with +`getattr(client, "iter_memberships", None)` exactly as `post_is_gated` already +is. A client that does not implement it (Discord, HentaiFoundry) makes the +whole feature invisible for that platform — no flag, no config row, no +"unsupported" branch to keep alive. + Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we depend on (top-level `data`, media resources carrying `file_name`/`url`) are the contract. If a response comes back as an HTML login page or a media @@ -52,8 +64,34 @@ from .native_ingest_common import ( log = logging.getLogger(__name__) _POSTS_URL = "https://www.patreon.com/api/posts" +_MEMBERS_URL = "https://www.patreon.com/api/members" +_CURRENT_USER_URL = "https://www.patreon.com/api/current_user" _TIMEOUT_SECONDS = 30.0 +# --- membership roster contract (#387 C2) --------------------------------- +# Characterized from a real capture of the operator's own session — Scribe note +# #3886. NOT from Patreon's public v2 API, which is the CREATOR api behind +# OAuth scopes and a different surface entirely (project rule 130). +# +# DELIBERATELY MINIMAL, and that is a privacy decision rather than a +# performance one. The web app's own include set pulls `latest_pledge.card` +# and `address`; the card resources come back carrying the ACCOUNT HOLDER'S +# EMAIL in `merchant_name`. Copying the browser's query string wholesale — the +# obvious move — would have FC fetching payment PII it has no use for and can +# only mishandle. We ask for the creator and the tier, and nothing else. +_MEMBERS_INCLUDE = "campaign,reward" +_FIELDS_MEMBER = ( + "patron_status,is_free_member,is_gifted,pledge_amount_cents,currency," + "pledge_cadence,next_charge_date,access_expires_at" +) +_FIELDS_MEMBERS_CAMPAIGN = "name,url,vanity,is_active" +_FIELDS_REWARD = "title" +# The browser sends 1000. Whether a server-side ceiling applies below that is +# untested (note #3886, open question 4), so page conservatively: a wrong guess +# costs one extra request, and the paging loop is driven by meta.pagination +# rather than by this number. +_MEMBERS_PAGE_COUNT = 200 + # JSON:API request contract (observed from real traffic — see module plan). _INCLUDE = ( "campaign,access_rules,attachments,attachments_media,audio,images,media," @@ -125,6 +163,44 @@ class MediaItem: post_id: str +@dataclass +class Membership: + """One membership the ACCOUNT holds, as the roster needs it (#387 C2). + + Deliberately not a raw JSON:API row: the sweep (C3) should not have to know + that a tier lives behind a `reward` relationship, and `platform_membership` + should not gain columns because Patreon shapes things a certain way. + + `status` carries the PLATFORM's own word, verbatim and unmapped + (`active_patron`, `former_patron`, ...). Deciding what it means is the read + site's job — `membership_roster.has_paid_access` — precisely so an + unrecognised word records as evidence rather than as a decision. + + `is_free_member` is SEPARATE from status and must stay that way. The + capture shows Patreon expressing a free follow as this boolean rather than + as a status value, so "does the account pay for this" is + `status == "active_patron" and not is_free_member` — a question the status + string alone cannot answer. NOTE: the capture contains no ACTIVE free + member, so the two fields are perfectly correlated in that sample; the + separation is what the schema says, not something the sample proves. + """ + + campaign_id: str + display_name: str | None + url: str | None + vanity: str | None + status: str | None + is_free_member: bool + tier_names: list[str] + amount_cents: int | None + currency: str | None + # Everything the roster did not model, kept so a later question can be + # answered without another authenticated round-trip. Scoped to the member's + # own attributes plus the campaign's — never the raw page, which is where + # the card/address resources live. + details: dict + + def _filehash(url: str) -> str | None: # Delegate to the shared extractor (utils.paths) so capture-time persistence # and render-time inline-image matching use the EXACT same identity. @@ -182,20 +258,27 @@ class PatreonClient: params["page[cursor]"] = cursor return params - def _fetch(self, campaign_id: str, cursor: str | None) -> dict: + def _request(self, url: str, params: dict[str, str], *, what: str, scope: str) -> dict: + """One paced, retried, error-classified GET returning parsed JSON. + + Extracted from `_fetch` so the membership endpoint (#387 C2) rides the + SAME request path rather than growing a second copy of the 429 backoff, + the auth-vs-drift classification and the Retry-After plumbing. Two + copies of this would drift, and the half that drifted would be the one + that only runs once a day. + + `what` / `scope` only shape the messages ("posts"/"campaign_id=123"), + so a failure still says which call failed and against what. + """ if self._request_sleep > 0: time.sleep(self._request_sleep) # pace the API endpoint attempt = 0 while True: try: - resp = self._session.get( - _POSTS_URL, - params=self._params(campaign_id, cursor), - timeout=_TIMEOUT_SECONDS, - ) + resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS) except requests.RequestException as exc: raise PatreonAPIError( - f"Patreon posts request failed (campaign_id={campaign_id}): {exc}" + f"Patreon {what} request failed ({scope}): {exc}" ) from exc # Transient rate-limit: back off and retry rather than failing the @@ -205,8 +288,8 @@ class PatreonClient: attempt += 1 delay = retry_after_seconds(resp, attempt) log.warning( - "Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)", - campaign_id, delay, attempt, self._max_retries, + "Patreon 429 (%s) — backing off %.1fs (retry %d/%d)", + scope, delay, attempt, self._max_retries, ) time.sleep(delay) continue @@ -216,9 +299,8 @@ class PatreonClient: # Auth rejected — expired/missing cookies or an insufficient tier. # Actionable as "rotate credentials", so it's auth, not drift/http. raise PatreonAuthError( - f"Patreon posts API returned HTTP {resp.status_code} — auth " - f"rejected (cookies expired or tier insufficient; " - f"campaign_id={campaign_id})", + f"Patreon {what} API returned HTTP {resp.status_code} — auth " + f"rejected (cookies expired or tier insufficient; {scope})", status_code=resp.status_code, ) if resp.status_code != 200: @@ -234,24 +316,27 @@ class PatreonClient: except (TypeError, ValueError): retry_after = None raise PatreonAPIError( - f"Patreon posts API returned HTTP {resp.status_code} " - f"(campaign_id={campaign_id})", + f"Patreon {what} API returned HTTP {resp.status_code} ({scope})", status_code=resp.status_code, retry_after=retry_after, ) try: - payload = resp.json() + return resp.json() except ValueError as exc: # A non-JSON body here is almost always the HTML login/challenge # page served when cookies are missing/expired — that is an AUTH # failure (rotate cookies), not API drift (update the ingester) and # not a transient network error. raise PatreonAuthError( - "Patreon posts API returned a non-JSON response (likely an " - f"HTML login/challenge page — session expired; " - f"campaign_id={campaign_id}): {exc}" + f"Patreon {what} API returned a non-JSON response (likely an " + f"HTML login/challenge page — session expired; {scope}): {exc}" ) from exc - return payload + + def _fetch(self, campaign_id: str, cursor: str | None) -> dict: + return self._request( + _POSTS_URL, self._params(campaign_id, cursor), + what="posts", scope=f"campaign_id={campaign_id}", + ) # -- parsing ----------------------------------------------------------- @@ -510,6 +595,159 @@ class PatreonClient: return current_cursor = next_cursor + # -- membership roster (#387 C2) --------------------------------------- + + def current_user_id(self) -> str: + """The signed-in account's own numeric user id. + + Needed because `/api/members` is filtered by `filter[user_id]` — the + endpoint answers "who are the members of X", and the account asking + about ITSELF still has to say so. + + INFERRED, NOT CHARACTERIZED. C0 captured `/api/members`, not this; what + is relied on here is only the JSON:API envelope (`data.id`), which this + same API demonstrably uses everywhere else. If that inference is wrong + it raises drift rather than returning something plausible — which is + the right failure, because the alternative is a confidently empty + roster and an empty roster means "cancel everything" to C4. + """ + payload = self._request( + _CURRENT_USER_URL, {"json-api-version": "1.0"}, + what="current_user", scope="self", + ) + data = (payload or {}).get("data") + if not isinstance(data, dict) or not data.get("id"): + raise PatreonDriftError( + "Patreon current_user response had no data.id — cannot scope " + "the membership roster to this account" + ) + return str(data["id"]) + + def _members_params(self, user_id: str | None, offset: int) -> dict[str, str]: + params = { + "include": _MEMBERS_INCLUDE, + "fields[member]": _FIELDS_MEMBER, + "fields[campaign]": _FIELDS_MEMBERS_CAMPAIGN, + "fields[reward]": _FIELDS_REWARD, + "page[offset]": str(offset), + "page[count]": str(_MEMBERS_PAGE_COUNT), + "json-api-version": "1.0", + "json-api-use-default-includes": "false", + } + if user_id: + params["filter[user_id]"] = user_id + # NOTE: `filter[membership_type]` is deliberately NOT sent. The browser + # sends the six values its settings page wants to show, and the capture + # proves that list is NOT the same vocabulary as the `patron_status` + # attribute — a row selected as `free_member` came back with + # `patron_status: former_patron`, a word absent from the filter. Sending + # no filter asks for everything the endpoint will give, which is what a + # roster wants: a membership that DISAPPEARS is the signal C4 reads, and + # a filter tuned for a UI that hides lapses would manufacture exactly + # that disappearance. (Note #3886, open question 1.) + return params + + @staticmethod + def _validate_members_response(response: dict) -> None: + """Drift checks specific to the roster. + + Stricter than the posts path about pagination on purpose: `iter_posts` + can treat a missing `links.next` as "that was the last page", but here + a missing total is indistinguishable from a truncated page — and a + roster that silently stops half way reads downstream as "you cancelled + those", which is the worst wrong answer this feature can give. + """ + PatreonClient._validate_response(response) + meta = response.get("meta") + if not isinstance(meta, dict): + raise PatreonDriftError("Patreon members response missing 'meta'") + pagination = meta.get("pagination") + if not isinstance(pagination, dict) or "total" not in pagination: + raise PatreonDriftError( + "Patreon members response missing meta.pagination.total — " + "cannot tell a complete roster from a truncated one" + ) + + def _membership(self, member: dict, index: dict) -> Membership: + attrs = member.get("attributes") or {} + if "patron_status" not in attrs: + raise PatreonDriftError( + "Patreon member resource has no patron_status attribute" + ) + + campaign_ids = self._related_ids(member, "campaign") + if not campaign_ids: + raise PatreonDriftError( + "Patreon member resource has no campaign relationship — a " + "membership we cannot attribute to a creator is not usable" + ) + campaign_id = campaign_ids[0] + campaign = index.get(("campaign", campaign_id)) or {} + + # A member has at most one reward, and `reward.data` is legitimately + # null — an active patron with no tier. Absence is a fact about the + # membership, not a parse failure. + tier_names: list[str] = [] + for reward_id in self._related_ids(member, "reward"): + title = (index.get(("reward", reward_id)) or {}).get("title") + if title: + tier_names.append(str(title)) + + return Membership( + campaign_id=campaign_id, + display_name=campaign.get("name"), + url=campaign.get("url"), + vanity=campaign.get("vanity"), + status=attrs.get("patron_status"), + # Default False, not None: the attribute is always present in the + # capture, and treating a missing one as "free" would understate + # access rather than overstate it. + is_free_member=bool(attrs.get("is_free_member")), + tier_names=tier_names, + # The MEMBER's amount, never the reward's. `reward.amount_cents` is + # the creator's list price in the CREATOR's currency (the capture + # has CAD, DKK and EUR rewards sitting on USD pledges), so reading + # it would report a number the operator has never been charged. + amount_cents=attrs.get("pledge_amount_cents"), + currency=attrs.get("currency"), + details={"member": attrs, "campaign": campaign}, + ) + + def iter_memberships(self, user_id: str | None = None) -> Iterator[Membership]: + """Yield every membership the account holds. + + Pages on `page[offset]`/`page[count]` against `meta.pagination.total` — + NOT on `links`. The response's own `links.first` is built without the + `/api/` prefix the request uses, so following it verbatim would hit the + web page instead of the API (note #3886). + + `user_id` omitted means the `filter[user_id]` parameter is omitted. + Whether the endpoint then defaults to self is UNTESTED — pass + `current_user_id()` unless you are deliberately probing that. + """ + user_id = user_id or None + offset = 0 + seen = 0 + while True: + response = self._request( + _MEMBERS_URL, self._members_params(user_id, offset), + what="members", scope="membership roster", + ) + self._validate_members_response(response) + index = self._transform(response) + rows = [m for m in (response.get("data") or []) if isinstance(m, dict)] + for member in rows: + yield self._membership(member, index) + + seen += len(rows) + total = int(response["meta"]["pagination"]["total"] or 0) + # An empty page terminates regardless of what `total` claims. Trusting + # the total alone would spin forever against a server that reports + # more rows than it will hand over. + if not rows or seen >= total: + return + offset += len(rows) + # -- detail (full body enrichment) ------------------------------------- def fetch_post_detail_content(self, post_id: str) -> str | None: diff --git a/tests/fixtures/patreon_members_page1.json b/tests/fixtures/patreon_members_page1.json new file mode 100644 index 0000000..8c8052e --- /dev/null +++ b/tests/fixtures/patreon_members_page1.json @@ -0,0 +1,723 @@ +{ + "data": [ + { + "id": "00000000-0000-4000-8000-000000000000", + "type": "member", + "attributes": { + "access_expires_at": "2026-01-02T00:00:00.000+00:00", + "currency": "USD", + "gift_paid_conversion_reward_cadence": null, + "grant_type": null, + "is_free_member": true, + "is_gifted": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00", + "patron_status": "former_patron", + "pledge_amount_cents": null, + "pledge_cadence": 1 + }, + "relationships": { + "address": { + "data": null + }, + "billing_subscription_scheduled_event": { + "data": null + }, + "campaign": { + "data": { + "id": "1000001", + "type": "campaign" + } + }, + "gift_paid_conversion_payment_method": { + "data": null + }, + "latest_pledge": { + "data": { + "id": "700001", + "type": "pledge" + } + }, + "previous_pledge": { + "data": null + }, + "reward": { + "data": { + "id": "90001", + "type": "reward" + } + } + } + }, + { + "id": "00000000-0000-4000-8000-000000000001", + "type": "member", + "attributes": { + "access_expires_at": null, + "currency": "USD", + "gift_paid_conversion_reward_cadence": null, + "grant_type": null, + "is_free_member": false, + "is_gifted": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00", + "patron_status": "active_patron", + "pledge_amount_cents": 1000, + "pledge_cadence": 1 + }, + "relationships": { + "address": { + "data": null + }, + "billing_subscription_scheduled_event": { + "data": null + }, + "campaign": { + "data": { + "id": "1000002", + "type": "campaign" + } + }, + "gift_paid_conversion_payment_method": { + "data": null + }, + "latest_pledge": { + "data": { + "id": "700002", + "type": "pledge" + } + }, + "previous_pledge": { + "data": null + }, + "reward": { + "data": null + } + } + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "type": "member", + "attributes": { + "access_expires_at": null, + "currency": "USD", + "gift_paid_conversion_reward_cadence": null, + "grant_type": null, + "is_free_member": false, + "is_gifted": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00", + "patron_status": "active_patron", + "pledge_amount_cents": 1500, + "pledge_cadence": 12 + }, + "relationships": { + "address": { + "data": null + }, + "billing_subscription_scheduled_event": { + "data": null + }, + "campaign": { + "data": { + "id": "1000003", + "type": "campaign" + } + }, + "gift_paid_conversion_payment_method": { + "data": null + }, + "latest_pledge": { + "data": { + "id": "700003", + "type": "pledge" + } + }, + "previous_pledge": { + "data": { + "id": "700004", + "type": "pledge" + } + }, + "reward": { + "data": { + "id": "90002", + "type": "reward" + } + } + } + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "type": "member", + "attributes": { + "access_expires_at": null, + "currency": "USD", + "gift_paid_conversion_reward_cadence": null, + "grant_type": null, + "is_free_member": false, + "is_gifted": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00", + "patron_status": "active_patron", + "pledge_amount_cents": 2000, + "pledge_cadence": 1 + }, + "relationships": { + "address": { + "data": null + }, + "billing_subscription_scheduled_event": { + "data": null + }, + "campaign": { + "data": { + "id": "1000004", + "type": "campaign" + } + }, + "gift_paid_conversion_payment_method": { + "data": null + }, + "latest_pledge": { + "data": { + "id": "700005", + "type": "pledge" + } + }, + "previous_pledge": { + "data": { + "id": "700006", + "type": "pledge" + } + }, + "reward": { + "data": { + "id": "90003", + "type": "reward" + } + } + } + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "type": "member", + "attributes": { + "access_expires_at": null, + "currency": "USD", + "gift_paid_conversion_reward_cadence": null, + "grant_type": null, + "is_free_member": false, + "is_gifted": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00", + "patron_status": "active_patron", + "pledge_amount_cents": 2500, + "pledge_cadence": 1 + }, + "relationships": { + "address": { + "data": null + }, + "billing_subscription_scheduled_event": { + "data": null + }, + "campaign": { + "data": { + "id": "1000005", + "type": "campaign" + } + }, + "gift_paid_conversion_payment_method": { + "data": null + }, + "latest_pledge": { + "data": { + "id": "700007", + "type": "pledge" + } + }, + "previous_pledge": { + "data": null + }, + "reward": { + "data": { + "id": "90004", + "type": "reward" + } + } + } + }, + { + "id": "00000000-0000-4000-8000-000000000005", + "type": "member", + "attributes": { + "access_expires_at": null, + "currency": "USD", + "gift_paid_conversion_reward_cadence": null, + "grant_type": null, + "is_free_member": false, + "is_gifted": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00", + "patron_status": "active_patron", + "pledge_amount_cents": 3000, + "pledge_cadence": 1 + }, + "relationships": { + "address": { + "data": null + }, + "billing_subscription_scheduled_event": { + "data": null + }, + "campaign": { + "data": { + "id": "1000006", + "type": "campaign" + } + }, + "gift_paid_conversion_payment_method": { + "data": null + }, + "latest_pledge": { + "data": { + "id": "700008", + "type": "pledge" + } + }, + "previous_pledge": { + "data": null + }, + "reward": { + "data": { + "id": "90005", + "type": "reward" + } + } + } + } + ], + "included": [ + { + "id": "1000001", + "type": "campaign", + "attributes": { + "annual_pledging_enabled": false, + "avatar_photo_url": "https://example.invalid/avatar.jpg", + "cover_photo_url": "https://example.invalid/cover.jpg", + "is_active": true, + "is_monthly": true, + "is_non_profit": false, + "name": "Creator One", + "owner_id": 5000000, + "pay_per_name": "month", + "pledge_url": "https://www.patreon.com/checkout/creator-one", + "published_at": "2020-01-01T00:00:00.000+00:00", + "url": "https://www.patreon.com/creator-one", + "url_for_current_user": "https://www.patreon.com/cw/creator-one", + "vanity": "creator-one" + }, + "relationships": { + "current_user_free_membership_subscription": { + "data": { + "id": "80000000", + "type": "free-membership-subscription" + } + }, + "current_user_gift": { + "data": null + }, + "rewards": { + "data": [ + { + "id": "-1", + "type": "reward" + } + ] + } + } + }, + { + "id": "1000002", + "type": "campaign", + "attributes": { + "annual_pledging_enabled": false, + "avatar_photo_url": "https://example.invalid/avatar.jpg", + "cover_photo_url": "https://example.invalid/cover.jpg", + "is_active": true, + "is_monthly": true, + "is_non_profit": false, + "name": "Creator Two", + "owner_id": 5000001, + "pay_per_name": "month", + "pledge_url": "https://www.patreon.com/checkout/creator-two", + "published_at": "2021-01-01T00:00:00.000+00:00", + "url": "https://www.patreon.com/creator-two", + "url_for_current_user": "https://www.patreon.com/c/creator-two", + "vanity": "creator-two" + }, + "relationships": { + "current_user_free_membership_subscription": { + "data": null + }, + "current_user_gift": { + "data": null + }, + "rewards": { + "data": [ + { + "id": "-1", + "type": "reward" + } + ] + } + } + }, + { + "id": "1000003", + "type": "campaign", + "attributes": { + "annual_pledging_enabled": true, + "avatar_photo_url": "https://example.invalid/avatar.jpg", + "cover_photo_url": "https://example.invalid/cover.jpg", + "is_active": true, + "is_monthly": true, + "is_non_profit": false, + "name": "Creator Three", + "owner_id": 5000002, + "pay_per_name": "month", + "pledge_url": "https://www.patreon.com/checkout/creator-three", + "published_at": "2022-01-01T00:00:00.000+00:00", + "url": "https://www.patreon.com/creator-three", + "url_for_current_user": "https://www.patreon.com/c/creator-three", + "vanity": "creator-three" + }, + "relationships": { + "current_user_free_membership_subscription": { + "data": null + }, + "current_user_gift": { + "data": null + }, + "rewards": { + "data": [ + { + "id": "-1", + "type": "reward" + } + ] + } + } + }, + { + "id": "1000004", + "type": "campaign", + "attributes": { + "annual_pledging_enabled": true, + "avatar_photo_url": "https://example.invalid/avatar.jpg", + "cover_photo_url": "https://example.invalid/cover.jpg", + "is_active": true, + "is_monthly": true, + "is_non_profit": false, + "name": "Creator Four", + "owner_id": 5000003, + "pay_per_name": "month", + "pledge_url": "https://www.patreon.com/checkout/creator-four", + "published_at": "2023-01-01T00:00:00.000+00:00", + "url": "https://www.patreon.com/creator-four", + "url_for_current_user": "https://www.patreon.com/cw/creator-four", + "vanity": "creator-four" + }, + "relationships": { + "current_user_free_membership_subscription": { + "data": null + }, + "current_user_gift": { + "data": null + }, + "rewards": { + "data": [ + { + "id": "-1", + "type": "reward" + } + ] + } + } + }, + { + "id": "1000005", + "type": "campaign", + "attributes": { + "annual_pledging_enabled": true, + "avatar_photo_url": "https://example.invalid/avatar.jpg", + "cover_photo_url": "https://example.invalid/cover.jpg", + "is_active": true, + "is_monthly": true, + "is_non_profit": false, + "name": "Creator Five", + "owner_id": 5000004, + "pay_per_name": "month", + "pledge_url": "https://www.patreon.com/checkout/creator-five", + "published_at": "2024-01-01T00:00:00.000+00:00", + "url": "https://www.patreon.com/creator-five", + "url_for_current_user": "https://www.patreon.com/c/creator-five", + "vanity": "creator-five" + }, + "relationships": { + "current_user_free_membership_subscription": { + "data": null + }, + "current_user_gift": { + "data": null + }, + "rewards": { + "data": [ + { + "id": "-1", + "type": "reward" + } + ] + } + } + }, + { + "id": "1000006", + "type": "campaign", + "attributes": { + "annual_pledging_enabled": false, + "avatar_photo_url": "https://example.invalid/avatar.jpg", + "cover_photo_url": "https://example.invalid/cover.jpg", + "is_active": true, + "is_monthly": true, + "is_non_profit": false, + "name": "Creator Six", + "owner_id": 5000005, + "pay_per_name": "month", + "pledge_url": "https://www.patreon.com/checkout/creator-six", + "published_at": "2025-01-01T00:00:00.000+00:00", + "url": "https://www.patreon.com/creator-six", + "url_for_current_user": "https://www.patreon.com/cw/creator-six", + "vanity": "creator-six" + }, + "relationships": { + "current_user_free_membership_subscription": { + "data": null + }, + "current_user_gift": { + "data": null + }, + "rewards": { + "data": [ + { + "id": "-1", + "type": "reward" + } + ] + } + } + }, + { + "id": "90001", + "type": "reward", + "attributes": { + "amount_cents": 0, + "currency": "EUR", + "description": "

Tier benefits.

", + "image_url": null, + "patron_amount_cents": 100, + "requires_shipping": false, + "title": "Tier A", + "unpublished_at": null + } + }, + { + "id": "90002", + "type": "reward", + "attributes": { + "amount_cents": 499, + "currency": "USD", + "description": "

Tier benefits.

", + "image_url": "https://example.invalid/reward.png", + "patron_amount_cents": 499, + "requires_shipping": false, + "title": "Tier B", + "unpublished_at": null + } + }, + { + "id": "90003", + "type": "reward", + "attributes": { + "amount_cents": 400, + "currency": "USD", + "description": "

Tier benefits.

", + "image_url": "https://example.invalid/reward.png", + "patron_amount_cents": 400, + "requires_shipping": false, + "title": "Tier C", + "unpublished_at": null + } + }, + { + "id": "90004", + "type": "reward", + "attributes": { + "amount_cents": 570, + "currency": "CAD", + "description": "

Tier benefits.

", + "image_url": "https://example.invalid/reward.png", + "patron_amount_cents": 450, + "requires_shipping": false, + "title": "Tier D", + "unpublished_at": null + } + }, + { + "id": "90005", + "type": "reward", + "attributes": { + "amount_cents": 1000, + "currency": "USD", + "description": "

Tier benefits.

", + "image_url": "https://example.invalid/reward.png", + "patron_amount_cents": 1000, + "requires_shipping": false, + "title": "Tier E", + "unpublished_at": null + } + }, + { + "id": "700001", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 1, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + }, + "relationships": {} + }, + { + "id": "700002", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 1, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + }, + "relationships": {} + }, + { + "id": "700003", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 12, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + }, + "relationships": {} + }, + { + "id": "700004", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 12, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + } + }, + { + "id": "700005", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 1, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + }, + "relationships": {} + }, + { + "id": "700006", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 1, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + } + }, + { + "id": "700007", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 1, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + }, + "relationships": {} + }, + { + "id": "700008", + "type": "pledge", + "attributes": { + "amount_cents": 500, + "cadence": 1, + "currency": "USD", + "is_apple_iap_subscription": false, + "is_grandfathered": false, + "next_charge_date": "2026-12-15T00:00:00.000+00:00" + }, + "relationships": {} + }, + { + "id": "-1", + "type": "reward", + "attributes": { + "amount": 0, + "amount_cents": 0, + "description": "Everyone", + "user_limit": null, + "remaining": 0, + "requires_shipping": false, + "created_at": null, + "url": null, + "patron_currency": "USD" + } + }, + { + "id": "00000000-0000-4000-8000-ffffffffffff", + "type": "card", + "attributes": { + "card_type": "PayPal", + "expiration_date": null, + "merchant_name": "billing@example.invalid", + "number": null + } + } + ], + "links": { + "first": "https://www.patreon.com/members?page%5Boffset%5D=0" + }, + "meta": { + "count": 6, + "sort": "-pledge_relationship_start", + "pagination": { + "total": 6 + } + } +} diff --git a/tests/test_membership_roster.py b/tests/test_membership_roster.py index 3172866..6d5b2fa 100644 --- a/tests/test_membership_roster.py +++ b/tests/test_membership_roster.py @@ -153,15 +153,46 @@ def test_unknown_platform_is_unknown(): assert has_paid_access("a-platform-with-no-mapping", "active") is None -def test_the_status_map_starts_empty_and_that_is_deliberate(): +def test_the_status_map_contains_only_characterised_values(): """Guards project rule 130 at the one place it is easiest to break. - Every entry must come from a characterised response (step C0), never from - API docs or a plausible-looking guess. If this assertion fails, either C0 - happened — in which case update this test along with the map, citing the - capture — or somebody guessed, which is the thing the rule exists to stop. + Every entry must come from a characterised response, never from API docs or + a plausible-looking guess. This started life asserting the map was EMPTY; + C0 then captured Patreon's real `/api/members` response (Scribe note #3886) + and this assertion is the confirmation step — updated with the capture, not + ahead of it. + + `declined_patron` is absent ON PURPOSE and must stay absent until a capture + shows it in `patron_status`. It appears in the request's + `filter[membership_type]`, and the capture proved that filter is a + different vocabulary from the attribute: a row the filter selected as + `free_member` came back as `former_patron`, a word the filter does not + contain. Adding it because it "obviously" belongs is precisely the guess + this test exists to stop. """ - assert MEMBERSHIP_STATUS == {} + assert MEMBERSHIP_STATUS == { + "patreon": {"active_patron": True, "former_patron": False}, + } + + +def test_a_free_member_does_not_count_as_paid_access(): + """The second axis. Patreon expresses a free follow as a boolean beside + `patron_status`, so a CURRENT membership can still be one nobody pays for — + and reporting that as paid access would hide it from C4 forever.""" + assert has_paid_access("patreon", "active_patron") is True + assert has_paid_access("patreon", "active_patron", is_free_member=True) is False + + +def test_the_free_flag_cannot_rescue_a_lapsed_membership(): + """False from the status is terminal: not-free does not mean still-paying.""" + assert has_paid_access("patreon", "former_patron") is False + assert has_paid_access("patreon", "former_patron", is_free_member=False) is False + + +def test_an_unknown_status_stays_unknown_whatever_the_free_flag_says(): + """The free flag refines a KNOWN answer; it never manufactures one.""" + assert has_paid_access("patreon", "declined_patron") is None + assert has_paid_access("patreon", "declined_patron", is_free_member=True) is None def test_the_map_is_consulted_once_it_has_entries(monkeypatch): diff --git a/tests/test_patreon_memberships.py b/tests/test_patreon_memberships.py new file mode 100644 index 0000000..be2e44f --- /dev/null +++ b/tests/test_patreon_memberships.py @@ -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)