CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m36s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m11s
Membership moves from patreon_client to native_ingest_common, beside PostRecordOutcome, for exactly the reason that one lives there: it is the seam's contract rather than the first platform's. Left where it was, D1 would have had to import the shape it implements from the module of the platform it is being mirrored FROM - which inverts the dependency and is how a seam advertised as portable quietly stays Patreon-shaped. Found by C7's own pass, which is the point of running C7 before D1 rather than writing it up afterwards: this is invisible while there is only one implementer and load-bearing the moment there are two. No behaviour change. Three files, no shim (rule 122): patreon_client imports it, the dataclass keeps its docstring, and the test imports from the seam's home. The docstring gains what the contract owes a second platform - that a missing field supplies the empty answer and never a guess: no tiers -> [], no pledge -> None (absent stays distinguishable from zero, since "free" and "we don't know" are different answers), no vanity -> None with identity falling back to the URL tail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""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.native_ingest_common import Membership
|
|
from backend.app.services.patreon_client import (
|
|
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)
|