Files
FabledCurator/tests/test_membership_roster.py
T
bvandeusenandClaude Opus 5 4fe792b61c
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 39s
Build images / build-web (push) Successful in 1m14s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m18s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m21s
feat: platform_membership — the learned roster of what the account pays for (milestone 387 step C1)
FC knows which creators it was TOLD to follow and nothing about which
ones the operator is subscribed to. Those two sets drift both ways and
neither drift is currently visible: a subscription FC doesn't track is
content the operator believes they're archiving and aren't, and a
source walked after the subscription lapsed is requests spent on a wall
reported as a creator gone quiet.

Sibling of service_seen (milestone 365) and the same insight — an
absence is only observable against a record of presence. touch_membership
reuses the recorded touch_service shape (snippet 3447): upsert rather
than read-modify-write, first_seen_at deliberately outside the update
set because it's the one field that makes a later DISAPPEARANCE
readable as a lapse rather than as a creator we never knew.

Nothing populates it yet, and that's the intended intermediate state.
The sweep (C3) needs a client seam (C2) that needs Patreon's real
response characterised from a captured sample (C0), which needs the
operator's browser session. The table's SHAPE doesn't wait on that,
because it's deliberately free-form exactly where C0's findings would
otherwise dictate a column.

status is an unconstrained String holding the PLATFORM's own word, not
a normalised FC value. Rule 36 considered and declined, same reasoning
service_seen.kind records: the vocabulary isn't ours to invent, and
picking a lowest-common-denominator enum before any platform has been
characterised would bake a guess into the schema. The service owns the
whitelist and the mapping; the column owns the evidence.

MEMBERSHIP_STATUS ships EMPTY, guarded by a test that fails if anyone
adds an entry — every one must come from a characterised response, not
from API docs. That's rule 130 at the one place it's easiest to break,
and the failure message says so.

has_paid_access returns None, never False, for a word it hasn't been
taught. The difference is load-bearing: False means the operator lost
access, which C4 turns into an offer to disable the source, so
asserting it from an unrecognised word would tell them to cancel a
subscription they're still paying for.

Retention decided here rather than deferred (rule 89): a membership
that stops appearing is aged out on time, never deleted on absence —
deleting would destroy the signal at the moment it became interesting.

Rule 90 check, done on the right thing this time: the per-test TRUNCATE
teardown derives its table list from Base.metadata.sorted_tables, so
the new table is picked up automatically; test_models asserts a subset,
so it doesn't break. 0091 follows 0090 on the collapsed baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-10 10:58:25 -04:00

176 lines
6.5 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_starts_empty_and_that_is_deliberate():
"""Guards project rule 130 at the one place it is easiest to break.
Every entry must come from a characterised response (step C0), never from
API docs or a plausible-looking guess. If this assertion fails, either C0
happened — in which case update this test along with the map, citing the
capture — or somebody guessed, which is the thing the rule exists to stop.
"""
assert MEMBERSHIP_STATUS == {}
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