What you pay for, what Discord drops, and pixiv switched off #251
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
+723
@@ -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": "<p>Tier benefits.</p>",
|
||||
"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": "<p>Tier benefits.</p>",
|
||||
"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": "<p>Tier benefits.</p>",
|
||||
"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": "<p>Tier benefits.</p>",
|
||||
"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": "<p>Tier benefits.</p>",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user