feat: platform_membership — the learned roster of what the account pays for (milestone 387 step C1)
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
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
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
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
"""platform_membership — the learned roster of what the account actually pays for.
|
||||||
|
|
||||||
|
Milestone 387, phase C. FC knows which creators it was told to follow and
|
||||||
|
nothing about which ones the operator is subscribed to; this table is the
|
||||||
|
memory that makes the drift in both directions observable. See the model
|
||||||
|
docstring for why the roster is learned rather than looked up live, and why
|
||||||
|
`status` holds the platform's own word rather than a normalised FC value.
|
||||||
|
|
||||||
|
## Nothing populates this yet, on purpose
|
||||||
|
|
||||||
|
The sweep that fills it (C3) depends on a client seam (C2) that depends on
|
||||||
|
characterising Patreon's real membership response from a captured sample (C0),
|
||||||
|
which needs the operator's authenticated browser session. The table's SHAPE
|
||||||
|
does not wait on that: it is deliberately free-form where C0's findings would
|
||||||
|
otherwise dictate a column — `status` is an unconstrained String and `details`
|
||||||
|
keeps the raw payload — so no capture can invalidate what is created here.
|
||||||
|
|
||||||
|
An empty table is the correct intermediate state. It is not dead code: C5 reads
|
||||||
|
it to explain a tier-limited source, and C4 reads it to reconcile.
|
||||||
|
|
||||||
|
Revision ID: 0091
|
||||||
|
Revises: 0090
|
||||||
|
Create Date: 2026-09-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0091"
|
||||||
|
down_revision: Union[str, None] = "0090"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"platform_membership",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("platform", sa.String(length=64), nullable=False),
|
||||||
|
# Text, not a bounded String: an opaque upstream identifier we do not
|
||||||
|
# mint, and guessing a ceiling for one is how a walk dies on a silent
|
||||||
|
# truncation.
|
||||||
|
sa.Column("external_campaign_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("display_name", sa.Text(), nullable=True),
|
||||||
|
sa.Column("url", sa.Text(), nullable=True),
|
||||||
|
# No CHECK, deliberately (rule 36 considered and declined): the
|
||||||
|
# vocabulary is each platform's own and is not ours to fix before C0
|
||||||
|
# has characterised even one of them. The service owns the whitelist.
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("tier_names", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("amount_cents", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=8), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"first_seen_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"last_seen_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")),
|
||||||
|
# The upsert's conflict target. Named explicitly because
|
||||||
|
# touch_membership references it by name in ON CONFLICT — an
|
||||||
|
# autogenerated name would make that call break on a rename nobody
|
||||||
|
# connected to it.
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"platform", "external_campaign_id",
|
||||||
|
name="uq_platform_membership_platform_campaign",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# No secondary indexes. This table holds one row per subscription — tens,
|
||||||
|
# not millions — so every query against it is a short scan and an index
|
||||||
|
# would be write cost buying nothing (#3301 removed seven of that shape).
|
||||||
|
# The unique constraint above already backs the only lookup that matters.
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("platform_membership")
|
||||||
@@ -26,6 +26,7 @@ from .patreon_failed_media import PatreonFailedMedia
|
|||||||
from .patreon_seen_media import PatreonSeenMedia
|
from .patreon_seen_media import PatreonSeenMedia
|
||||||
from .pixiv_failed_media import PixivFailedMedia
|
from .pixiv_failed_media import PixivFailedMedia
|
||||||
from .pixiv_seen_media import PixivSeenMedia
|
from .pixiv_seen_media import PixivSeenMedia
|
||||||
|
from .platform_membership import PlatformMembership
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .post_attachment import PostAttachment, attachment_download_url
|
from .post_attachment import PostAttachment, attachment_download_url
|
||||||
from .presentation_review import PresentationReview
|
from .presentation_review import PresentationReview
|
||||||
@@ -64,6 +65,7 @@ __all__ = [
|
|||||||
"SeriesChapter",
|
"SeriesChapter",
|
||||||
"SeriesPage",
|
"SeriesPage",
|
||||||
"SeriesSuggestion",
|
"SeriesSuggestion",
|
||||||
|
"PlatformMembership",
|
||||||
"ServiceSeen",
|
"ServiceSeen",
|
||||||
"ImageRecord",
|
"ImageRecord",
|
||||||
"ImageProvenance",
|
"ImageProvenance",
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""platform_membership — the learned roster of what the account actually pays for.
|
||||||
|
|
||||||
|
Milestone 387, phase C. FabledCurator knows which creators it has been TOLD to
|
||||||
|
follow (`source`), and nothing about which ones the operator is actually
|
||||||
|
subscribed to. Those two sets drift in both directions and the app cannot
|
||||||
|
currently see either drift:
|
||||||
|
|
||||||
|
* A subscription the operator pays for that FC does not track is content they
|
||||||
|
believe they are archiving and are not.
|
||||||
|
* A source FC keeps walking after the subscription lapsed is requests spent on
|
||||||
|
a wall, reported as a creator who has gone quiet.
|
||||||
|
|
||||||
|
This table is the memory that makes both visible — every membership the account
|
||||||
|
has been observed to hold, and when it was last seen.
|
||||||
|
|
||||||
|
## Why a learned roster rather than a live lookup
|
||||||
|
|
||||||
|
Same reasoning as `service_seen` (milestone 365), and the same shape: an
|
||||||
|
absence is only observable against a record of presence. A membership that
|
||||||
|
stops appearing in a sweep is the signal — "you were subscribed to this, now
|
||||||
|
you aren't" — and there is nowhere to read that from a live call, because a
|
||||||
|
live call returns what IS, never what stopped being.
|
||||||
|
|
||||||
|
It also means the reconciliation surface keeps working when Patreon is
|
||||||
|
unreachable, degraded to a stale roster with a visible age rather than an empty
|
||||||
|
page (rule 164).
|
||||||
|
|
||||||
|
## Roster truth, NOT per-post truth
|
||||||
|
|
||||||
|
The single most important thing about this table: `tier_names` says which tiers
|
||||||
|
the account holds. It does **not** say which posts those tiers unlock. A
|
||||||
|
creator can gate a post behind an access rule that maps onto no tier name at
|
||||||
|
all.
|
||||||
|
|
||||||
|
`current_user_can_view` — read per post by `patreon_client.post_is_gated` — is
|
||||||
|
the authoritative signal, and phase A already turned it into a durable
|
||||||
|
per-source state. This roster EXPLAINS that state ("you are no longer a patron"
|
||||||
|
vs "your tier doesn't cover these posts"). It must never be used to decide
|
||||||
|
whether to fetch something. Getting that backwards would make FC silently stop
|
||||||
|
fetching content the operator is paying for, which is the worst failure
|
||||||
|
available in this milestone.
|
||||||
|
|
||||||
|
## status is a plain String, and deliberately the platform's own word
|
||||||
|
|
||||||
|
Not a Postgres ENUM, not CHECK-gated — matching `service_seen.kind`,
|
||||||
|
`gpu_job.status` and `source.error_type`. Two reasons, and the first is the
|
||||||
|
real one:
|
||||||
|
|
||||||
|
1. **The vocabulary is not ours to invent.** Patreon says `active_patron` /
|
||||||
|
`former_patron` / `declined_patron`; SubscribeStar and FANBOX will say
|
||||||
|
something else. Storing each platform's own word verbatim and mapping to
|
||||||
|
FC's meaning at the READ site keeps this table a record of what was
|
||||||
|
observed rather than a lossy translation of it. A lowest-common-denominator
|
||||||
|
enum picked before any platform has been characterised (step C0) would be a
|
||||||
|
guess baked into the schema.
|
||||||
|
2. A constraint swap per new value (rule 36) would be cost with no invariant
|
||||||
|
behind it, exactly as `service_seen.kind` records.
|
||||||
|
|
||||||
|
The service layer owns the whitelist and the mapping; the column owns the
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
## Retention: aged out, never deleted on disappearance
|
||||||
|
|
||||||
|
A membership that stops appearing in a sweep is NOT removed. Its disappearance
|
||||||
|
is the fact the reconciliation surface reads, and deleting the row would
|
||||||
|
destroy the signal at the moment it became interesting. `last_seen_at` is what
|
||||||
|
makes "gone" decidable, and a retention policy ages rows out on time rather
|
||||||
|
than on absence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformMembership(Base):
|
||||||
|
__tablename__ = "platform_membership"
|
||||||
|
__table_args__ = (
|
||||||
|
# The natural key the sweep's upsert conflicts on. Named explicitly
|
||||||
|
# because `touch_membership` references it by name in ON CONFLICT.
|
||||||
|
UniqueConstraint(
|
||||||
|
"platform", "external_campaign_id",
|
||||||
|
name="uq_platform_membership_platform_campaign",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
platform: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# The platform's own id for the thing subscribed to — a Patreon campaign
|
||||||
|
# id, whatever SubscribeStar and FANBOX call theirs. Text rather than a
|
||||||
|
# bounded String: these are opaque upstream identifiers and guessing a
|
||||||
|
# ceiling for a value we do not mint is how a walk dies on a truncation.
|
||||||
|
external_campaign_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
# For the reconciliation UI, and for matching against Source.url — the
|
||||||
|
# vanity/URL is what the two sides actually have in common.
|
||||||
|
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# The platform's own word. See the module docstring — this is evidence,
|
||||||
|
# not a normalised FC status.
|
||||||
|
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|
||||||
|
# Nullable throughout: a free follow has no tier and no money attached, and
|
||||||
|
# a platform may not expose an amount at all. Absent must stay
|
||||||
|
# distinguishable from zero — "free" and "we don't know" are different
|
||||||
|
# answers to "what is this costing".
|
||||||
|
tier_names: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||||
|
|
||||||
|
# NEVER updated after insert. The one field that answers "has this ever
|
||||||
|
# been true", which is what makes a disappearance readable rather than
|
||||||
|
# indistinguishable from never having existed. `touch_membership`
|
||||||
|
# deliberately excludes it from the ON CONFLICT update set.
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The raw membership as the platform returned it, so a later question can
|
||||||
|
# be answered without re-fetching — and so a field we did not think to
|
||||||
|
# model is not lost. Displayed and never queried, like service_seen.details.
|
||||||
|
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""The learned membership roster: what the account actually subscribes to.
|
||||||
|
|
||||||
|
Milestone 387, phase C. Sibling of `service_roster` (milestone 365) and built
|
||||||
|
on the same insight — an absence is only observable against a record of
|
||||||
|
presence. There, a stopped worker; here, a subscription that lapsed.
|
||||||
|
|
||||||
|
## Nothing calls this yet
|
||||||
|
|
||||||
|
`touch_membership` is written before its caller because the caller (the sweep,
|
||||||
|
C3) needs a client seam (C2) that needs Patreon's real response characterised
|
||||||
|
from a captured sample (C0), and that capture needs the operator's browser
|
||||||
|
session. The write side does not depend on any of it: an upsert keyed on
|
||||||
|
(platform, external_campaign_id) is the same regardless of what the payload
|
||||||
|
turns out to look like, and `details` carries whatever C0 finds.
|
||||||
|
|
||||||
|
## Why the whitelist lives here and not in the column
|
||||||
|
|
||||||
|
`platform_membership.status` is an unconstrained String holding the PLATFORM's
|
||||||
|
own word — `active_patron`, not some normalised FC value. The mapping from
|
||||||
|
those words to FC's meaning is a read-site concern and belongs in code that can
|
||||||
|
be corrected without a migration, because the vocabulary comes from whatever
|
||||||
|
each platform says and will be discovered per platform rather than designed up
|
||||||
|
front. `MEMBERSHIP_STATUS` below is a place for that knowledge to accumulate as
|
||||||
|
platforms are characterised; it is deliberately empty of guesses today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import PlatformMembership
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Platform word -> whether the account currently has paid access.
|
||||||
|
#
|
||||||
|
# EMPTY ON PURPOSE. Every entry here must come from a characterised response
|
||||||
|
# (step C0), not from what the API docs or a plausible guess suggest — that is
|
||||||
|
# the whole point of project rule 130, and inventing `active_patron` before
|
||||||
|
# seeing it in a real payload is exactly the failure it names. Populate per
|
||||||
|
# platform as each is characterised.
|
||||||
|
#
|
||||||
|
# Unknown words are NOT an error: an unrecognised status means the roster
|
||||||
|
# records evidence it cannot yet interpret, which is a better state than
|
||||||
|
# dropping the row or asserting a meaning for it.
|
||||||
|
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def has_paid_access(platform: str, status: str | None) -> bool | None:
|
||||||
|
"""Does this status mean the account currently pays for access?
|
||||||
|
|
||||||
|
Returns None for a status this code has not been taught, which callers must
|
||||||
|
treat as "unknown" rather than as False. The difference matters: False says
|
||||||
|
the operator has lost access, and asserting that from an unrecognised word
|
||||||
|
would tell them to cancel a source they are still paying for.
|
||||||
|
"""
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
entry = MEMBERSHIP_STATUS.get(platform, {})
|
||||||
|
return entry.get(status)
|
||||||
|
|
||||||
|
|
||||||
|
async def touch_membership(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
platform: str,
|
||||||
|
external_campaign_id: str,
|
||||||
|
display_name: str | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
tier_names: list | None = None,
|
||||||
|
amount_cents: int | None = None,
|
||||||
|
currency: str | None = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record that this membership was observed just now.
|
||||||
|
|
||||||
|
Upsert rather than read-modify-write, for the same reason as
|
||||||
|
`service_roster.touch_service`: a sweep may overlap its own previous run,
|
||||||
|
and the last writer is simply the most recent sighting.
|
||||||
|
|
||||||
|
`first_seen_at` is deliberately NOT in the update set. It is the one field
|
||||||
|
that answers "has this ever been true", which is what makes a membership's
|
||||||
|
later DISAPPEARANCE readable as a lapse rather than indistinguishable from
|
||||||
|
a creator FC never knew about. Every other column is last-writer-wins,
|
||||||
|
including status — a membership that goes from active to former must move.
|
||||||
|
"""
|
||||||
|
stmt = pg_insert(PlatformMembership).values(
|
||||||
|
platform=platform,
|
||||||
|
external_campaign_id=external_campaign_id,
|
||||||
|
display_name=display_name,
|
||||||
|
url=url,
|
||||||
|
status=status,
|
||||||
|
tier_names=tier_names,
|
||||||
|
amount_cents=amount_cents,
|
||||||
|
currency=currency,
|
||||||
|
details=details or {},
|
||||||
|
)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
constraint="uq_platform_membership_platform_campaign",
|
||||||
|
set_={
|
||||||
|
"display_name": stmt.excluded.display_name,
|
||||||
|
"url": stmt.excluded.url,
|
||||||
|
"status": stmt.excluded.status,
|
||||||
|
"tier_names": stmt.excluded.tier_names,
|
||||||
|
"amount_cents": stmt.excluded.amount_cents,
|
||||||
|
"currency": stmt.excluded.currency,
|
||||||
|
"details": stmt.excluded.details,
|
||||||
|
"last_seen_at": func.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user