Files
FabledCurator/tests/test_membership_roster.py
T
bvandeusenandClaude Opus 5 ef91fcfd26
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
feat: SubscribeStar joins the membership roster (387 D1)
The second platform through the seam note 3970 contracted, characterized first from a live capture of the account's /subscriptions page (note 3989). The capture lives in the gitignored captures dir; the committed fixture is hand-built with invented values and was verified tag-for-tag against it - card wrappers, both table heads, and every distinct row shape - before any code depended on it.

What the page is, and the three decisions it forced:

The table IS the status. SubscribeStar has no per-row status word: a creator is either in the active_subscriptions card or the cancelled_subscriptions one. The card's data-identifier is stored verbatim as Membership.status and mapped in MEMBERSHIP_STATUS, keyed on the identifier rather than the table class because the cancelled table's class names the same list differently (for-unsubscribed_users).

The creator's numeric data-user-id is the key, not the slug. A slug re-keys when a creator renames; the old row stops appearing; and a disappearance is exactly what reconciliation reads as a lapse. Keyed on the slug, a rename would have told a paying subscriber they had cancelled. The slug rides as vanity, where the identity join already looks for a handle.

Price is kept as text, never parsed into amount_cents. A bare $ names no currency and a page price is not proven to be the charge - 3970 finding 4. Tier names live behind a per-row modal and are not fetched.

Refusals, because SubscribeStar offers nothing like Patreon's meta.pagination.total and every conclusion downstream is drawn from absence. The parser raises when: the active card is missing (auth error on a login/age wall, drift otherwise); a row lacks a numeric creator id or a creator link; anything renders after a card's table; or the page carries a page= link. Both cards are paginatable (app#embed_pagination) and the captured account was too small to show what pagination looks like, so possible pagination is a roster FC cannot prove complete. A loud error on a larger account beats a quiet half-list. A missing cancelled card is not drift, and a creator in both tables is reported once, as active.

Fetched from subscribestar.adult, not the .art the capture came from: FC's requests never clear the .art age wall with the 18+ cookie (1259, 1284). Whether /subscriptions on .adult authenticates exactly as .art did in the browser is untested - if not, the sweep records a visible error and C6 shows its unavailable rung.

The seam leak D1 found. Note 3970 promised a second platform would be one builders line plus the client method. The sweep instead called current_user_id() on every client, which only Patreon's has, so SubscribeStar would have raised AttributeError on the first sweep. roster_user_id probes it with getattr, the same way the sweep already probes iter_memberships.

Two existing tests were passing for the wrong reason and now can fail:
- "a platform that has never been characterised says nothing" named SubscribeStar, and stayed green only because active_patron is not a SubscribeStar word. Now uses hentaifoundry, with a positive SubscribeStar test beside it.
- the freshness test gave SubscribeStar a Patreon word, so the vocabulary excluded it and deleting the freshness gate outright would have left it green. It now uses cancelled_subscriptions, making the gate the only thing that excludes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
2026-09-13 10:53:01 -04:00

211 lines
8.3 KiB
Python

"""Milestone 387 C1: the learned membership roster.
The load-bearing property is that `first_seen_at` survives every re-observation
— it is what makes a membership's later DISAPPEARANCE readable as a lapse
rather than indistinguishable from a creator FC never knew about. Everything
else is last-writer-wins, including status, because a membership that goes from
active to former has to move.
"""
import pytest
from sqlalchemy import select
from backend.app.models import PlatformMembership
from backend.app.services.membership_roster import (
MEMBERSHIP_STATUS,
has_paid_access,
touch_membership,
)
pytestmark = pytest.mark.integration
async def _row(db, platform="patreon", campaign="c1"):
return (await db.execute(
select(PlatformMembership).where(
PlatformMembership.platform == platform,
PlatformMembership.external_campaign_id == campaign,
)
)).scalar_one()
@pytest.mark.asyncio
async def test_touch_inserts_a_new_membership(db):
await touch_membership(
db, platform="patreon", external_campaign_id="c-new",
display_name="Maewix Studios", url="https://patreon.com/maewix",
status="some_platform_word", tier_names=["Sketches"],
amount_cents=500, currency="USD", details={"raw": 1},
)
await db.commit()
row = await _row(db, campaign="c-new")
assert row.display_name == "Maewix Studios"
assert row.status == "some_platform_word"
assert row.tier_names == ["Sketches"]
assert row.amount_cents == 500
assert row.details == {"raw": 1}
assert row.first_seen_at is not None
@pytest.mark.asyncio
async def test_re_observation_preserves_first_seen_and_advances_last_seen(db):
"""The whole point of the table.
Committed between touches deliberately: `func.now()` is the TRANSACTION
timestamp in Postgres, so two touches in one transaction would share a
last_seen_at and this test would pass without proving anything. (That
sharing is correct for a sweep — every row it touches is one observation —
but it makes an in-transaction assertion vacuous.)
"""
await touch_membership(
db, platform="patreon", external_campaign_id="c-again", status="active_ish",
)
await db.commit()
original = await _row(db, campaign="c-again")
first_seen, first_last_seen = original.first_seen_at, original.last_seen_at
db.expunge_all()
await touch_membership(
db, platform="patreon", external_campaign_id="c-again", status="former_ish",
)
await db.commit()
row = await _row(db, campaign="c-again")
assert row.first_seen_at == first_seen, "first_seen_at must never move"
assert row.last_seen_at >= first_last_seen
# Status is last-writer-wins: a lapse has to be able to overwrite.
assert row.status == "former_ish"
@pytest.mark.asyncio
async def test_re_observation_overwrites_the_mutable_fields(db):
"""A creator who renames, retiers or changes price must not leave the
roster asserting the old value — every column except first_seen_at moves."""
await touch_membership(
db, platform="patreon", external_campaign_id="c-mut",
display_name="Old Name", amount_cents=500, tier_names=["Cheap"],
details={"v": 1},
)
await db.commit()
db.expunge_all()
await touch_membership(
db, platform="patreon", external_campaign_id="c-mut",
display_name="New Name", amount_cents=1500, tier_names=["Pricey"],
details={"v": 2},
)
await db.commit()
row = await _row(db, campaign="c-mut")
assert row.display_name == "New Name"
assert row.amount_cents == 1500
assert row.tier_names == ["Pricey"]
assert row.details == {"v": 2}
@pytest.mark.asyncio
async def test_the_same_campaign_id_on_two_platforms_is_two_rows(db):
"""The key is (platform, external_campaign_id). Nothing stops two platforms
minting the same opaque id, and collapsing them would merge one creator's
membership into another's."""
await touch_membership(db, platform="patreon", external_campaign_id="shared-id")
await touch_membership(db, platform="subscribestar", external_campaign_id="shared-id")
await db.commit()
rows = (await db.execute(
select(PlatformMembership).where(
PlatformMembership.external_campaign_id == "shared-id"
)
)).scalars().all()
assert {r.platform for r in rows} == {"patreon", "subscribestar"}
@pytest.mark.asyncio
async def test_a_free_follow_keeps_absent_distinct_from_zero(db):
""""Free" and "we don't know what this costs" are different answers, and
the reconciliation UI has to be able to tell them apart."""
await touch_membership(
db, platform="patreon", external_campaign_id="c-free", status="free_ish",
)
await db.commit()
row = await _row(db, campaign="c-free")
assert row.amount_cents is None
assert row.tier_names is None
# --- has_paid_access: unknown must never read as "lost access" -------------
def test_unknown_status_is_unknown_not_false():
"""The dangerous case. False means "the operator lost access", which C4
turns into an offer to disable the source. Asserting that from a word this
code simply has not been taught would tell them to cancel a subscription
they are still paying for."""
assert has_paid_access("patreon", "a_word_nobody_characterised_yet") is None
def test_absent_status_is_unknown():
assert has_paid_access("patreon", None) is None
def test_unknown_platform_is_unknown():
assert has_paid_access("a-platform-with-no-mapping", "active") is None
def test_the_status_map_contains_only_characterised_values():
"""Guards project rule 130 at the one place it is easiest to break.
Every entry must come from a characterised response, never from API docs or
a plausible-looking guess. This started life asserting the map was EMPTY;
C0 then captured Patreon's real `/api/members` response (Scribe note #3886)
and this assertion is the confirmation step — updated with the capture, not
ahead of it.
`declined_patron` is absent ON PURPOSE and must stay absent until a capture
shows it in `patron_status`. It appears in the request's
`filter[membership_type]`, and the capture proved that filter is a
different vocabulary from the attribute: a row the filter selected as
`free_member` came back as `former_patron`, a word the filter does not
contain. Adding it because it "obviously" belongs is precisely the guess
this test exists to stop.
"""
# subscribestar added with its own capture (note #3989, 2026-09-13). Its
# "words" are the two table identifiers, because the page carries no
# per-row status at all.
assert MEMBERSHIP_STATUS == {
"patreon": {"active_patron": True, "former_patron": False},
"subscribestar": {"active_subscriptions": True, "cancelled_subscriptions": 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):
"""The map is empty today, so exercise the lookup with a stand-in — proving
the plumbing works without pretending to know a real platform's word."""
monkeypatch.setitem(
MEMBERSHIP_STATUS, "testplat", {"paying": True, "lapsed": False},
)
assert has_paid_access("testplat", "paying") is True
assert has_paid_access("testplat", "lapsed") is False
assert has_paid_access("testplat", "something_else") is None