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
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
211 lines
8.3 KiB
Python
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
|