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)