CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Failing after 35s
CI / frontend-build (push) Successful in 32s
Build images / build-web (push) Successful in 1m20s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m17s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
The first live sweep failed with "Patreon member resource has no campaign relationship". The operator ran FC's exact /api/members request in a browser and shared the response. - It has 104 rows. The browser's settings page shows 43, because FC sends no membership-type filter and so also gets lapses back to 2016. - Exactly one row has no `campaign` key at all: a former_patron whose membership ended in 2017. Its included reward has no campaign link either, so the creator's page is gone. - Every other row, including the 4 is_active=false campaigns, has a campaign. _membership returns None for a campaign-less row only when has_paid_access says it is definitely not paying (a known lapsed status, or a free member), and iter_memberships skips it. That changes no conclusion. A lapsed membership and an absent one both mean "not paying", and no Source can match a campaign with no id. An active or unrecognised membership without a campaign still raises, because dropping a membership that might be paid would read downstream as a cancellation. Paging still counts the rows the server sent, not the rows kept. A test pins that, so a skip can't re-read an offset or stop a page short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
346 lines
14 KiB
Python
346 lines
14 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 _index_of(payload, status):
|
|
return next(
|
|
i for i, m in enumerate(payload["data"])
|
|
if m["attributes"]["patron_status"] == status
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("shape", ["null", "absent"])
|
|
def test_an_active_member_with_no_campaign_is_drift(client, payload, shape):
|
|
"""A PAID membership FC cannot attribute must refuse the roster. Dropping it
|
|
would read downstream as the operator having cancelled it."""
|
|
mangled = json.loads(json.dumps(payload))
|
|
rels = mangled["data"][_index_of(mangled, "active_patron")]["relationships"]
|
|
if shape == "null":
|
|
rels["campaign"] = {"data": None}
|
|
else:
|
|
del rels["campaign"]
|
|
client._request = lambda *a, **k: mangled
|
|
with pytest.raises(PatreonDriftError, match="campaign"):
|
|
list(client.iter_memberships(user_id="1"))
|
|
|
|
|
|
def test_an_unrecognised_status_with_no_campaign_is_drift(client, payload):
|
|
"""Only a status KNOWN to mean "not paying" may be skipped. An unknown word
|
|
could be a paid membership (has_paid_access returns None for it)."""
|
|
mangled = json.loads(json.dumps(payload))
|
|
row = mangled["data"][_index_of(mangled, "active_patron")]
|
|
row["attributes"]["patron_status"] = "some_new_status"
|
|
del row["relationships"]["campaign"]
|
|
client._request = lambda *a, **k: mangled
|
|
with pytest.raises(PatreonDriftError, match="campaign"):
|
|
list(client.iter_memberships(user_id="1"))
|
|
|
|
|
|
def test_a_lapsed_member_whose_creator_is_gone_is_skipped(client, payload):
|
|
"""The live roster's shape (note #3886, CORRECTION 3). A membership that
|
|
lapsed in 2017 came back with no `campaign` key at all, because the creator's
|
|
page no longer exists. It used to fail the whole roster. Now it is the only
|
|
row missing, and every other row still arrives."""
|
|
mangled = json.loads(json.dumps(payload))
|
|
lapsed = _index_of(mangled, "former_patron")
|
|
del mangled["data"][lapsed]["relationships"]["campaign"]
|
|
client._request = lambda *a, **k: mangled
|
|
rows = list(client.iter_memberships(user_id="1"))
|
|
assert len(rows) == len(payload["data"]) - 1
|
|
assert all(r.campaign_id for r in rows)
|
|
assert all(r.status == "active_patron" for r in rows)
|
|
|
|
|
|
def test_skipping_does_not_end_paging_early(client, payload):
|
|
"""Paging counts the rows the SERVER sent, not the ones kept. Counting kept
|
|
rows would re-request an offset that was already read, or stop one page
|
|
short, whenever a row is skipped."""
|
|
first = json.loads(json.dumps(payload))
|
|
del first["data"][_index_of(first, "former_patron")]["relationships"]["campaign"]
|
|
total = 2 * len(payload["data"])
|
|
first["meta"]["pagination"]["total"] = total
|
|
second = json.loads(json.dumps(payload))
|
|
second["meta"]["pagination"]["total"] = total
|
|
calls = _serve(client, first, second)
|
|
rows = list(client.iter_memberships(user_id="1"))
|
|
assert len(calls) == 2
|
|
assert calls[1][1]["page[offset]"] == str(len(payload["data"]))
|
|
assert len(rows) == total - 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)
|