fix: one lapsed membership to a deleted creator no longer fails the Patreon roster
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
This commit is contained in:
2026-09-13 16:01:50 -04:00
co-authored by Claude Opus 5
parent 57c880a623
commit 240f11c5aa
2 changed files with 95 additions and 4 deletions
+60 -2
View File
@@ -239,14 +239,72 @@ def test_a_missing_data_list_is_drift(client, payload):
list(client.iter_memberships(user_id="1"))
def test_a_member_with_no_campaign_is_drift(client, payload):
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))
mangled["data"][0]["relationships"]["campaign"] = {"data": None}
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"]