What you pay for, what Discord drops, and pixiv switched off #251
@@ -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