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.
|
# Platform word -> whether the account currently has paid access.
|
||||||
#
|
#
|
||||||
# EMPTY ON PURPOSE. Every entry here must come from a characterised response
|
# Every entry here must come from a CHARACTERISED response, never from API docs
|
||||||
# (step C0), not from what the API docs or a plausible guess suggest — that is
|
# or a plausible guess — project rule 130, and inventing a status before seeing
|
||||||
# the whole point of project rule 130, and inventing `active_patron` before
|
# it in a real payload is exactly the failure it names.
|
||||||
# seeing it in a real payload is exactly the failure it names. Populate per
|
#
|
||||||
# platform as each is characterised.
|
# 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
|
# Unknown words are NOT an error: an unrecognised status means the roster
|
||||||
# records evidence it cannot yet interpret, which is a better state than
|
# records evidence it cannot yet interpret, which is a better state than
|
||||||
# dropping the row or asserting a meaning for it.
|
# 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:
|
def has_paid_access(
|
||||||
"""Does this status mean the account currently pays for 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
|
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
|
treat as "unknown" rather than as False. The difference matters: False says
|
||||||
the operator has lost access, and asserting that from an unrecognised word
|
the operator has lost access, and asserting that from an unrecognised word
|
||||||
would tell them to cancel a source they are still paying for.
|
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:
|
if status is None:
|
||||||
return None
|
return None
|
||||||
entry = MEMBERSHIP_STATUS.get(platform, {})
|
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
|
||||||
return entry.get(status)
|
if known is None:
|
||||||
|
return None
|
||||||
|
if not known:
|
||||||
|
return False
|
||||||
|
return not is_free_member
|
||||||
|
|
||||||
|
|
||||||
async def touch_membership(
|
async def touch_membership(
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ the later step can drive it:
|
|||||||
- extract_media(post, included_index) → list[MediaItem]
|
- extract_media(post, included_index) → list[MediaItem]
|
||||||
- parse_cursor_from_url(url) → cursor
|
- 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
|
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
|
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
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_POSTS_URL = "https://www.patreon.com/api/posts"
|
_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
|
_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).
|
# JSON:API request contract (observed from real traffic — see module plan).
|
||||||
_INCLUDE = (
|
_INCLUDE = (
|
||||||
"campaign,access_rules,attachments,attachments_media,audio,images,media,"
|
"campaign,access_rules,attachments,attachments_media,audio,images,media,"
|
||||||
@@ -125,6 +163,44 @@ class MediaItem:
|
|||||||
post_id: str
|
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:
|
def _filehash(url: str) -> str | None:
|
||||||
# Delegate to the shared extractor (utils.paths) so capture-time persistence
|
# Delegate to the shared extractor (utils.paths) so capture-time persistence
|
||||||
# and render-time inline-image matching use the EXACT same identity.
|
# and render-time inline-image matching use the EXACT same identity.
|
||||||
@@ -182,20 +258,27 @@ class PatreonClient:
|
|||||||
params["page[cursor]"] = cursor
|
params["page[cursor]"] = cursor
|
||||||
return params
|
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:
|
if self._request_sleep > 0:
|
||||||
time.sleep(self._request_sleep) # pace the API endpoint
|
time.sleep(self._request_sleep) # pace the API endpoint
|
||||||
attempt = 0
|
attempt = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
resp = self._session.get(
|
resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS)
|
||||||
_POSTS_URL,
|
|
||||||
params=self._params(campaign_id, cursor),
|
|
||||||
timeout=_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise PatreonAPIError(
|
raise PatreonAPIError(
|
||||||
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
|
f"Patreon {what} request failed ({scope}): {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Transient rate-limit: back off and retry rather than failing the
|
# Transient rate-limit: back off and retry rather than failing the
|
||||||
@@ -205,8 +288,8 @@ class PatreonClient:
|
|||||||
attempt += 1
|
attempt += 1
|
||||||
delay = retry_after_seconds(resp, attempt)
|
delay = retry_after_seconds(resp, attempt)
|
||||||
log.warning(
|
log.warning(
|
||||||
"Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
|
"Patreon 429 (%s) — backing off %.1fs (retry %d/%d)",
|
||||||
campaign_id, delay, attempt, self._max_retries,
|
scope, delay, attempt, self._max_retries,
|
||||||
)
|
)
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
continue
|
continue
|
||||||
@@ -216,9 +299,8 @@ class PatreonClient:
|
|||||||
# Auth rejected — expired/missing cookies or an insufficient tier.
|
# Auth rejected — expired/missing cookies or an insufficient tier.
|
||||||
# Actionable as "rotate credentials", so it's auth, not drift/http.
|
# Actionable as "rotate credentials", so it's auth, not drift/http.
|
||||||
raise PatreonAuthError(
|
raise PatreonAuthError(
|
||||||
f"Patreon posts API returned HTTP {resp.status_code} — auth "
|
f"Patreon {what} API returned HTTP {resp.status_code} — auth "
|
||||||
f"rejected (cookies expired or tier insufficient; "
|
f"rejected (cookies expired or tier insufficient; {scope})",
|
||||||
f"campaign_id={campaign_id})",
|
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
@@ -234,24 +316,27 @@ class PatreonClient:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
retry_after = None
|
retry_after = None
|
||||||
raise PatreonAPIError(
|
raise PatreonAPIError(
|
||||||
f"Patreon posts API returned HTTP {resp.status_code} "
|
f"Patreon {what} API returned HTTP {resp.status_code} ({scope})",
|
||||||
f"(campaign_id={campaign_id})",
|
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = resp.json()
|
return resp.json()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# A non-JSON body here is almost always the HTML login/challenge
|
# A non-JSON body here is almost always the HTML login/challenge
|
||||||
# page served when cookies are missing/expired — that is an AUTH
|
# page served when cookies are missing/expired — that is an AUTH
|
||||||
# failure (rotate cookies), not API drift (update the ingester) and
|
# failure (rotate cookies), not API drift (update the ingester) and
|
||||||
# not a transient network error.
|
# not a transient network error.
|
||||||
raise PatreonAuthError(
|
raise PatreonAuthError(
|
||||||
"Patreon posts API returned a non-JSON response (likely an "
|
f"Patreon {what} API returned a non-JSON response (likely an "
|
||||||
f"HTML login/challenge page — session expired; "
|
f"HTML login/challenge page — session expired; {scope}): {exc}"
|
||||||
f"campaign_id={campaign_id}): {exc}"
|
|
||||||
) from 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 -----------------------------------------------------------
|
# -- parsing -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -510,6 +595,159 @@ class PatreonClient:
|
|||||||
return
|
return
|
||||||
current_cursor = next_cursor
|
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) -------------------------------------
|
# -- detail (full body enrichment) -------------------------------------
|
||||||
|
|
||||||
def fetch_post_detail_content(self, post_id: str) -> str | None:
|
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
|
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.
|
"""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
|
Every entry must come from a characterised response, never from API docs or
|
||||||
API docs or a plausible-looking guess. If this assertion fails, either C0
|
a plausible-looking guess. This started life asserting the map was EMPTY;
|
||||||
happened — in which case update this test along with the map, citing the
|
C0 then captured Patreon's real `/api/members` response (Scribe note #3886)
|
||||||
capture — or somebody guessed, which is the thing the rule exists to stop.
|
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):
|
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