diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index e7c2e15..4438c4c 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -7,7 +7,9 @@ from ..extensions import get_session from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source from ..services.artist_membership_service import ArtistMembershipService from ..services.artist_membership_service import rescan as membership_rescan -from ..services.membership_roster import roster_is_fresh +from ..services.artist_service import ArtistService +from ..services.membership_reconcile import reconcile_all +from ..services.membership_roster import roster_is_fresh, source_for_membership from ..services.scheduler_service import active_platform_cooldowns, scheduler_status from ..services.source_service import ( KNOWN_PLATFORMS, @@ -386,3 +388,68 @@ async def rescan_membership_suggestions(): result = await membership_rescan(session) await session.commit() return jsonify(result) + + +# --- #387 C4: reconciling the roster against what FC actually tracks ------- +# +# Asymmetric on purpose. The "you subscribe but FC doesn't follow it" direction +# carries a per-row action, because adding a source is the reversible half. The +# "FC follows it but your roster doesn't show it" direction is REPORT ONLY by +# the operator's decision (2026-09-11): it says what it sees and links to the +# Subscriptions row, and offers no one-click disable. + + +@sources_bp.route("/reconciliation", methods=["GET"]) +async def reconciliation(): + async with get_session() as session: + return jsonify(await reconcile_all(session)) + + +@sources_bp.route("/reconciliation/adopt", methods=["POST"]) +async def adopt_membership(): + """Start tracking a creator the roster says the account already pays for. + + One row, one click, never a sweep side effect: adding a source commits disk, + worker time and rate budget, and unwinding it means deleting files. + """ + body = await request.get_json() + if not isinstance(body, dict): + return _bad("invalid_body", status=400) + membership_id = body.get("membership_id") + if not isinstance(membership_id, int): + return _bad("membership_id_required", status=400) + + async with get_session() as session: + membership = await session.get(PlatformMembership, membership_id) + if membership is None: + return _bad("membership_not_found", status=404) + if not membership.url: + return _bad("membership_has_no_url", status=400) + + existing = await source_for_membership(session, membership) + if existing is not None: + # The operator got there by another route between the page load and + # the click. That is them being ahead of us, not an error. + return jsonify({"already_tracked": existing.id}) + + # The sweep already captured the creator's real display name, so the + # artist gets its true name with NO lookup on the request path. Task + # #1293 asked for `resolve_display_name` here; the roster satisfies that + # concern earlier in the pipeline than #1293 expected, which also keeps + # this route off the network entirely (rule 164). The vanity is the + # fallback, never the preferred value. + name = membership.display_name or membership.vanity_or_none() + if not name: + return _bad("membership_has_no_name", status=400) + + artist, _created = await ArtistService(session).find_or_create(name) + try: + record = await SourceService(session).create( + artist_id=artist.id, + platform=membership.platform, + url=membership.url, + ) + except DuplicateSourceError as exc: + return jsonify({"already_tracked": exc.existing_id}) + artist_id = artist.id + return jsonify({"source_id": record.id, "artist_id": artist_id}), 201 diff --git a/backend/app/services/artist_membership_service.py b/backend/app/services/artist_membership_service.py index 3d42b74..020c673 100644 --- a/backend/app/services/artist_membership_service.py +++ b/backend/app/services/artist_membership_service.py @@ -75,6 +75,7 @@ from ..models import ( ) from ..utils.slug import slugify from ..utils.text import html_to_plain +from .membership_roster import source_for_membership log = logging.getLogger(__name__) @@ -204,6 +205,14 @@ class ArtistMembershipService: membership = await self.session.get(PlatformMembership, membership_id) if membership is None: return 0 + # The shared identity join (C4), used here as the NEGATIVE check. A + # membership FC already has a source for is tracked — whoever it happens + # to be filed under — and proposing it to some OTHER artist would be + # exactly the wrong link this service exists to avoid making. + # `_candidate_artists` only knows whether a GIVEN artist has a source on + # the platform, which cannot see a source sitting under someone else. + if await source_for_membership(self.session, membership) is not None: + return 0 already = await self._decided(membership_id) made = 0 diff --git a/backend/app/services/membership_reconcile.py b/backend/app/services/membership_reconcile.py new file mode 100644 index 0000000..cd0df5e --- /dev/null +++ b/backend/app/services/membership_reconcile.py @@ -0,0 +1,243 @@ +"""Reconciling the learned roster against the sources FC actually tracks. + +Milestone 387, step C4. The step the operator asked for; C0-C3 are what make it +trustworthy enough to act on. + +## The buckets + +1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The + adoption win, and the only bucket carrying an action. +2. `tracked_not_subscribed` — FC follows this and the roster does not show you + paying for it. REPORT ONLY, by the operator's decision (2026-09-11): it says + what it sees and links to the existing Subscriptions row, and offers no + one-click disable. +3. `matched` — the healthy set. Counted, not listed loudly. +4. `unidentified` — sources this join cannot speak to at all. Reported as + exactly that, because the alternative is filing them under a verdict. + +## Why absence is the dangerous direction + +Bucket 1 is safe to be wrong about: the cost of offering a source the operator +does not want is one ignored row. Bucket 2 is not. It is computed from an +ABSENCE — no membership matched — and three different things produce that +absence: the subscription genuinely lapsed, the sweep failed, or the creator +renamed and this source has never been walked so no exact id was ever cached. + +Two guards follow from that, and they are the substance of this module: + +* the whole bucket is gated on `roster_is_fresh`, so a failed or never-run sweep + yields an empty list rather than a confident accusation (C3 built the state + this reads); +* every row carries the BASIS for its claim, so "your membership says former + patron" and "we know this creator's id and it is not in your roster" and "we + only have a URL handle to go on" are three different sentences rather than one + overconfident one. + +`has_paid_access` returning None is honoured throughout: unknown is never +rendered as lapsed. That is the whole reason it returns a tri-state. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Artist, MembershipSync, PlatformMembership, Source +from .membership_roster import ( + get_sync_state, + has_paid_access, + identity_keys_for_source, + match_kind, + roster_is_fresh, + url_tail, +) + +# Why a source appears in `tracked_not_subscribed`. Ordered strongest first — +# the UI renders a different sentence per basis, because collapsing them into +# one would make the weakest claim sound like the strongest. +BASIS_LAPSED = "lapsed" # a matched membership says access ended +BASIS_ABSENT_EXACT = "absent_exact" # exact id known, not in a fresh roster +BASIS_ABSENT_HANDLE = "absent_handle" # only a URL handle to go on + + +def _membership_row(m: PlatformMembership) -> dict: + return { + "id": m.id, + "platform": m.platform, + "external_campaign_id": m.external_campaign_id, + "display_name": m.display_name or m.vanity_or_none(), + "url": m.url, + "vanity": m.vanity_or_none(), + "status": m.status, + "tier_names": m.tier_names, + "amount_cents": m.amount_cents, + "currency": m.currency, + "paid_access": has_paid_access( + m.platform, m.status, + is_free_member=bool((m.details or {}).get("is_free_member")), + ), + } + + +def _source_row(source: Source, artist: Artist) -> dict: + return { + "id": source.id, + "platform": source.platform, + "url": source.url, + "enabled": source.enabled, + "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug}, + } + + +async def reconcile( + session: AsyncSession, *, platform: str, now: datetime | None = None, +) -> dict: + """Sort one platform's memberships and sources into the four buckets. + + Always returns the COMPLETE shape, including when the roster is not fresh — + a caller reading `len(result["tracked_not_subscribed"])` must not have to + check which keys exist first. `fresh` is what says whether the emptiness + means anything. + """ + state = await get_sync_state(session, platform) + fresh = roster_is_fresh(state, now=now) + + memberships = (await session.execute( + select(PlatformMembership).where(PlatformMembership.platform == platform) + )).scalars().all() + rows = (await session.execute( + select(Source, Artist) + .join(Artist, Artist.id == Source.artist_id) + .where(Source.platform == platform) + )).all() + + # Nested loop rather than a SQL join: the match is a predicate over a JSON + # blob and a derived URL handle, neither of which is indexable, and both + # sides are tens of rows on any real library. Keeping it in Python means ONE + # definition of identity (`match_kind`) instead of a second one in SQL that + # could drift from it. + pairs: dict[int, tuple[PlatformMembership, str]] = {} + matched_membership_ids: set[int] = set() + for source, _artist in rows: + for m in memberships: + kind = match_kind(source, m) + if kind: + pairs[source.id] = (m, kind) + matched_membership_ids.add(m.id) + break + + subscribed_not_tracked = [] + for m in memberships: + if m.id in matched_membership_ids: + continue + paid = has_paid_access( + m.platform, m.status, + is_free_member=bool((m.details or {}).get("is_free_member")), + ) + # A membership FC knows has ENDED is not an adoption opportunity — + # adding it would start a walk that can only fetch what is already + # public. Unknown (None) is still offered: the operator can judge it, + # and refusing to show it would hide a real subscription behind a word + # this code has not been taught. + if paid is False: + continue + subscribed_not_tracked.append(_membership_row(m)) + + tracked_not_subscribed = [] + matched = [] + unidentified = [] + for source, artist in rows: + pair = pairs.get(source.id) + if pair is not None: + m, kind = pair + paid = has_paid_access( + m.platform, m.status, + is_free_member=bool((m.details or {}).get("is_free_member")), + ) + if paid is False: + if not source.enabled: + # Already off. Reporting a source the operator has already + # stopped following is noise, not a finding. + continue + row = _source_row(source, artist) + row["basis"] = BASIS_LAPSED + row["matched_by"] = kind + row["membership"] = _membership_row(m) + tracked_not_subscribed.append(row) + else: + row = _source_row(source, artist) + row["matched_by"] = kind + row["membership"] = _membership_row(m) + matched.append(row) + continue + + # No membership matched. Whether that MEANS anything depends entirely on + # how well this source can be identified at all. + has_exact = bool(identity_keys_for_source(source)) + if not has_exact and url_tail(source.url) is None: + # Nothing to match on — a sidecar anchor or a URL with no handle. + # Reported as unidentified rather than silently dropped, so the + # counts add up to the source list the operator can see. + unidentified.append(_source_row(source, artist)) + continue + if not source.enabled: + # Already off. Telling the operator to stop following something they + # have stopped following is noise, not a finding. + continue + row = _source_row(source, artist) + row["basis"] = BASIS_ABSENT_EXACT if has_exact else BASIS_ABSENT_HANDLE + row["matched_by"] = None + row["membership"] = None + tracked_not_subscribed.append(row) + + # THE GATE. Everything above computed the bucket; this decides whether it may + # be shown. A stale or never-run roster makes every absence meaningless, and + # an absence rendered as a verdict is how this feature would tell the + # operator to cancel something they are still paying for. + if not fresh: + tracked_not_subscribed = [] + + return { + "platform": platform, + "fresh": fresh, + # How many sources exist on this platform at all. The UI needs it to + # decide whether an untrustworthy roster is worth mentioning: with no + # sources here there is nothing to reconcile, and a stale-roster warning + # would be noise on an install that simply has not started yet (that + # empty-install case is C6's, not this card's). + "tracked_total": len(rows), + "last_success_at": ( + state.last_success_at.isoformat() + if state is not None and state.last_success_at else None + ), + "subscribed_not_tracked": subscribed_not_tracked, + "tracked_not_subscribed": tracked_not_subscribed, + "matched": matched, + "unidentified": unidentified, + } + + +async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> dict: + """Every platform the roster knows about, in one payload for the UI. + + The platform list is the UNION of platforms with memberships and platforms + with sync state, not just the former. A sweep that has never succeeded has + recorded zero memberships, and deriving the list from memberships alone + would drop exactly that platform from the payload — making a broken + credential indistinguishable from a platform FC was never asked about. That + distinction is the whole reason C3 records sync state. + """ + with_memberships = (await session.execute( + select(PlatformMembership.platform).distinct() + )).scalars().all() + with_state = (await session.execute( + select(MembershipSync.platform) + )).scalars().all() + platforms = set(with_memberships) | set(with_state) + return { + "platforms": [ + await reconcile(session, platform=p, now=now) for p in sorted(platforms) + ] + } diff --git a/backend/app/services/membership_roster.py b/backend/app/services/membership_roster.py index e5d7568..8e9c43e 100644 --- a/backend/app/services/membership_roster.py +++ b/backend/app/services/membership_roster.py @@ -34,7 +34,7 @@ from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession -from ..models import MembershipSync, PlatformMembership +from ..models import MembershipSync, PlatformMembership, Source log = logging.getLogger(__name__) @@ -275,3 +275,96 @@ async def sync_platform( await session.commit() log.info("membership sync ok for %s: %d membership(s)", platform, len(memberships)) return {"platform": platform, "ok": True, "count": len(memberships)} + + +# --------------------------------------------------------------------------- +# Membership <-> Source identity (#387 C4) +# --------------------------------------------------------------------------- +# +# "Is this membership already tracked?" is asked by TWO features — C4's +# reconciliation buckets and E4's creator suggestions — and it lives here, once, +# on purpose. Built inline in C4 it would have looked finished while leaving E4 +# matching on name similarity alone, so the two would answer the same question +# differently and only one of them would be right. +# +# E4 and C4 use it from opposite sides: E4 as the NEGATIVE check (propose only +# where nothing matches) and C4 as the join itself. + +# Any platform that caches its creator id does so under this suffix; see +# `download_service._phase3_persist`, which writes `patreon_campaign_id`. +_CAMPAIGN_KEY_SUFFIX = "_campaign_id" + + +def identity_keys_for_source(source: Source) -> set[str]: + """Every platform-side creator id cached on this source. + + Reads ANY `_campaign_id` override rather than naming Patreon's, + so a second platform participates by caching its id under the same suffix — + no registry, no `if platform ==` branch (rule 169). A source that has never + been walked has cached nothing and simply contributes no exact key, which is + what makes the handle fallback below necessary rather than merely tolerated. + """ + keys = set() + for name, value in (source.config_overrides or {}).items(): + if name.endswith(_CAMPAIGN_KEY_SUFFIX) and isinstance(value, str) and value: + keys.add(value) + return keys + + +def url_tail(url: str | None) -> str | None: + """The creator handle at the end of a source URL, lowercased. + + Deliberately the same derivation as `PlatformMembership.vanity_or_none`'s + own fallback, so both sides of the comparison reduce a URL to a handle the + same way. Query strings and fragments are stripped first; Patreon's `/c/` + and `/cw/` forms both end in the vanity, so they need no special case (the + missing-`/c/` regex is what broke creator detection in #1485). + + Returns None for the pre-0030 `sidecar:` synthetic anchors, which are not + feeds and must never match anything. + """ + if not url or url.startswith("sidecar:"): + return None + cleaned = url.split("?", 1)[0].split("#", 1)[0] + tail = cleaned.rstrip("/").rsplit("/", 1)[-1] + return tail.lower() or None + + +def match_kind(source: Source, membership: PlatformMembership) -> str | None: + """How this source and this membership are known to be the same creator. + + Returns "campaign" for an exact platform-id match, "vanity" for agreeing URL + handles, or None for no evidence. + + THE ORDER MUST NOT BE INVERTED. The campaign id is exact and the handle is + not, but the id is only written AFTER a source has been walked at least once + — so checking the handle first would let a stale or renamed URL outvote the + authoritative id on every source FC has actually polled. + """ + if source.platform != membership.platform: + return None + if membership.external_campaign_id in identity_keys_for_source(source): + return "campaign" + vanity = membership.vanity_or_none() + tail = url_tail(source.url) + if vanity and tail and vanity.strip().lower() == tail: + return "vanity" + return None + + +async def source_for_membership( + session: AsyncSession, membership: PlatformMembership, +) -> Source | None: + """The source FC already tracks for this membership, if there is one. + + Scoped to the membership's own platform, so a creator tracked on Discord and + subscribed to on Patreon does not read as already-tracked — that pairing is + E4's suggestion to make, not an identity. + """ + rows = (await session.execute( + select(Source).where(Source.platform == membership.platform) + )).scalars().all() + for source in rows: + if match_kind(source, membership): + return source + return None diff --git a/frontend/src/components/subscriptions/MembershipReconcileCard.vue b/frontend/src/components/subscriptions/MembershipReconcileCard.vue new file mode 100644 index 0000000..5c7f511 --- /dev/null +++ b/frontend/src/components/subscriptions/MembershipReconcileCard.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue index b5ef2b2..16597e4 100644 --- a/frontend/src/components/subscriptions/SubscriptionsTab.vue +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -7,6 +7,9 @@ nothing to say. --> + +
@@ -327,6 +330,7 @@ import { usePlatformsStore } from '../../stores/platforms.js' import { useImportStore } from '../../stores/import.js' import NeedsAttentionCard from './NeedsAttentionCard.vue' import RecentArrivalsCard from './RecentArrivalsCard.vue' +import MembershipReconcileCard from './MembershipReconcileCard.vue' import SourceRow from './SourceRow.vue' import SourceCard from './SourceCard.vue' import SourceHealthDot from './SourceHealthDot.vue' diff --git a/frontend/src/stores/membershipReconcile.js b/frontend/src/stores/membershipReconcile.js new file mode 100644 index 0000000..66d6a90 --- /dev/null +++ b/frontend/src/stores/membershipReconcile.js @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' +import { toast } from '../utils/toast.js' + +// Backs the reconciliation card (#387 C4). Two directions with deliberately +// different weights: adopting a creator you already pay for is a per-row +// action, while "you follow this and your roster doesn't show it" only ever +// REPORTS — the operator disables from the Subscriptions row itself. +// +// `fresh` is the field that matters most here. A stale or never-run roster +// returns an empty tracked_not_subscribed list, and the card must say WHY it is +// empty rather than letting it read as "everything is fine". +export const useMembershipReconcileStore = defineStore('membershipReconcile', () => { + const api = useApi() + const platforms = ref([]) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) + + async function load () { + await run(async () => { + const body = await api.get('/api/sources/reconciliation') + platforms.value = body.platforms || [] + }) + } + + async function adopt (membershipId) { + try { + const res = await api.post('/api/sources/reconciliation/adopt', { + membership_id: membershipId + }) + toast({ + text: res.already_tracked + ? 'Already tracked — nothing to add' + : 'Now tracking this creator', + type: 'success' + }) + // Reload rather than splice: adopting moves the row from one bucket to + // another, and the counts beside them have to move with it. + await load() + } catch (e) { + toast({ text: `Could not add: ${e?.body?.detail || e.message}`, type: 'error' }) + } + } + + return { platforms, loading, error, load, adopt } +}) diff --git a/tests/test_artist_membership_suggestions.py b/tests/test_artist_membership_suggestions.py index 2a9bbe6..6e3c7fd 100644 --- a/tests/test_artist_membership_suggestions.py +++ b/tests/test_artist_membership_suggestions.py @@ -166,6 +166,30 @@ async def test_an_artist_already_on_that_platform_is_never_proposed(db): assert await ArtistMembershipService(db).match_membership(m.id) == 0 +@pytest.mark.asyncio +async def test_a_membership_already_tracked_elsewhere_is_never_proposed(db): + """C4's shared identity join, used here as the NEGATIVE check. + + A membership FC already has a source for is tracked — even when that source + sits under a DIFFERENT artist — and proposing it to this one would be the + wrong link this service exists to avoid. `_candidate_artists` cannot see + this on its own: it only knows whether the artist in front of it has a + source on the platform, not whether someone else already does. + """ + await _artist_with_discord(db, "Maewix", "maewix") + other = Artist(name="Someone Else", slug="someoneelse") + db.add(other) + await db.flush() + db.add(Source( + artist_id=other.id, platform="patreon", + url="https://www.patreon.com/maewix", enabled=True, + )) + m = await _membership(db, display_name="Maewix") + await db.commit() + + assert await ArtistMembershipService(db).match_membership(m.id) == 0 + + @pytest.mark.asyncio async def test_an_artist_with_no_sources_at_all_is_never_proposed(db): """Not a creator FC follows through another channel, which is the whole diff --git a/tests/test_membership_reconcile.py b/tests/test_membership_reconcile.py new file mode 100644 index 0000000..95bde7f --- /dev/null +++ b/tests/test_membership_reconcile.py @@ -0,0 +1,320 @@ +"""Milestone 387 C4: what you pay for, against what FC actually follows. + +The buckets are easy; the honesty is the work. `tracked_not_subscribed` is +computed from an ABSENCE — no membership matched this source — and three +different things produce that absence: the subscription really lapsed, the +sweep failed, or the creator renamed and this source has never been walked so +no exact id was ever cached. Only the first means what the bucket's name says. + +So most of what follows pins refusals: the gate that empties the bucket when the +roster cannot be trusted, the tri-state that keeps "unknown" out of "lapsed", +and the per-row basis that stops the weakest claim sounding like the strongest. +""" +from datetime import UTC, datetime, timedelta + +import pytest + +from backend.app.models import Artist, MembershipSync, PlatformMembership, Source +from backend.app.services.membership_reconcile import ( + BASIS_ABSENT_EXACT, + BASIS_ABSENT_HANDLE, + BASIS_LAPSED, + reconcile, + reconcile_all, +) +from backend.app.services.membership_roster import ROSTER_STALE_AFTER + +pytestmark = pytest.mark.integration + + +async def _artist(db, name="Maewix"): + a = Artist(name=name, slug=name.lower().replace(" ", "")) + db.add(a) + await db.flush() + return a + + +async def _source(db, artist, *, url, platform="patreon", enabled=True, overrides=None): + s = Source( + artist_id=artist.id, platform=platform, url=url, enabled=enabled, + config_overrides=overrides, + ) + db.add(s) + await db.flush() + return s + + +async def _membership( + db, *, campaign="c1", platform="patreon", status="active_patron", + display_name="Maewix", url="https://www.patreon.com/maewix", details=None, +): + m = PlatformMembership( + platform=platform, external_campaign_id=campaign, status=status, + display_name=display_name, url=url, + details={"campaign": {"vanity": "maewix"}} if details is None else details, + ) + db.add(m) + await db.flush() + return m + + +async def _synced(db, *, platform="patreon", ago=timedelta(hours=1)): + db.add(MembershipSync(platform=platform, last_success_at=datetime.now(UTC) - ago)) + await db.flush() + + +# --- the join -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_cached_campaign_id_matches_regardless_of_the_url(db): + """The exact key doing the work. The URL deliberately does NOT agree, so a + match here can only have come from the cached id.""" + a = await _artist(db) + await _source( + db, a, url="https://www.patreon.com/an-old-handle", + overrides={"patreon_campaign_id": "c1"}, + ) + await _membership(db, campaign="c1") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert len(out["matched"]) == 1 + assert out["matched"][0]["matched_by"] == "campaign" + assert out["subscribed_not_tracked"] == [] + assert out["tracked_not_subscribed"] == [] + + +@pytest.mark.asyncio +async def test_the_handle_matches_when_nothing_has_been_cached_yet(db): + """The fallback that exists because the id is only written AFTER a source + has been walked once — a freshly added source has no id at all.""" + a = await _artist(db) + await _source(db, a, url="https://www.patreon.com/maewix") + await _membership(db, campaign="c1") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert len(out["matched"]) == 1 + assert out["matched"][0]["matched_by"] == "vanity" + + +@pytest.mark.asyncio +async def test_the_c_and_cw_url_forms_both_reduce_to_the_vanity(db): + """Patreon serves /c/ and /cw/ (#3886). A handle derivation + that missed these is the exact class of bug in #1485.""" + a = await _artist(db) + await _source(db, a, url="https://www.patreon.com/c/maewix") + await _membership(db, campaign="c1") + await _synced(db) + await db.commit() + + assert len((await reconcile(db, platform="patreon"))["matched"]) == 1 + + +@pytest.mark.asyncio +async def test_the_same_id_on_another_platform_is_not_a_match(db): + """Nothing stops two platforms minting the same opaque id.""" + a = await _artist(db) + await _source( + db, a, url="https://subscribestar.adult/maewix", platform="subscribestar", + overrides={"subscribestar_campaign_id": "c1"}, + ) + await _membership(db, campaign="c1", platform="patreon") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert out["matched"] == [] + assert len(out["subscribed_not_tracked"]) == 1 + + +# --- the gate: an untrustworthy roster accuses nobody ----------------------- + + +@pytest.mark.asyncio +async def test_a_stale_roster_produces_no_unaccounted_sources(db): + """THE property. A sweep that stopped working a week ago must not turn every + source into "you no longer subscribe to this".""" + a = await _artist(db) + await _source( + db, a, url="https://www.patreon.com/tracked", + overrides={"patreon_campaign_id": "not-in-the-roster"}, + ) + await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(days=1)) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert out["fresh"] is False + assert out["tracked_not_subscribed"] == [] + + +@pytest.mark.asyncio +async def test_a_never_synced_roster_produces_no_unaccounted_sources(db): + """Never-synced is not zero. With no MembershipSync row at all there is no + evidence of anything, and the bucket must stay empty.""" + a = await _artist(db) + await _source( + db, a, url="https://www.patreon.com/tracked", + overrides={"patreon_campaign_id": "whatever"}, + ) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert out["fresh"] is False + assert out["last_success_at"] is None + assert out["tracked_not_subscribed"] == [] + + +@pytest.mark.asyncio +async def test_the_shape_is_complete_even_when_the_roster_is_stale(db): + """A caller must be able to read any bucket unconditionally — the E2 lesson + where a shorter disabled payload broke an exact-shape assertion.""" + await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(days=1)) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert set(out) == { + "platform", "fresh", "tracked_total", "last_success_at", + "subscribed_not_tracked", "tracked_not_subscribed", "matched", + "unidentified", + } + + +# --- what lands in the report-only bucket, and why ------------------------- + + +@pytest.mark.asyncio +async def test_a_lapsed_membership_reports_its_source_as_lapsed(db): + a = await _artist(db) + await _source(db, a, url="https://www.patreon.com/maewix") + await _membership(db, campaign="c1", status="former_patron") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert len(out["tracked_not_subscribed"]) == 1 + assert out["tracked_not_subscribed"][0]["basis"] == BASIS_LAPSED + assert out["matched"] == [] + + +@pytest.mark.asyncio +async def test_a_known_id_missing_from_a_fresh_roster_is_the_strong_claim(db): + a = await _artist(db) + await _source( + db, a, url="https://www.patreon.com/tracked", + overrides={"patreon_campaign_id": "not-in-the-roster"}, + ) + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert out["tracked_not_subscribed"][0]["basis"] == BASIS_ABSENT_EXACT + + +@pytest.mark.asyncio +async def test_a_handle_only_source_makes_the_weaker_claim(db): + """A renamed creator looks exactly like this, so it must not be phrased the + same way as the case where the id is known.""" + a = await _artist(db) + await _source(db, a, url="https://www.patreon.com/some-old-name") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert out["tracked_not_subscribed"][0]["basis"] == BASIS_ABSENT_HANDLE + + +@pytest.mark.asyncio +async def test_an_already_disabled_source_is_not_reported(db): + """Telling the operator to stop following something they have already + stopped following is noise, not a finding.""" + a = await _artist(db) + await _source(db, a, url="https://www.patreon.com/gone", enabled=False) + await _synced(db) + await db.commit() + + assert (await reconcile(db, platform="patreon"))["tracked_not_subscribed"] == [] + + +@pytest.mark.asyncio +async def test_a_sidecar_anchor_is_unidentified_not_unsubscribed(db): + """Pre-0030 synthetic anchors are not feeds. They cannot be matched, so they + are reported as unmatchable rather than filed under a verdict.""" + a = await _artist(db) + await _source(db, a, url="sidecar:patreon:maewix", enabled=False) + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert len(out["unidentified"]) == 1 + assert out["tracked_not_subscribed"] == [] + + +# --- what is offered for adoption, and what is withheld -------------------- + + +@pytest.mark.asyncio +async def test_a_membership_with_no_source_is_offered(db): + await _membership(db, campaign="c1") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert len(out["subscribed_not_tracked"]) == 1 + assert out["subscribed_not_tracked"][0]["paid_access"] is True + + +@pytest.mark.asyncio +async def test_a_lapsed_membership_is_not_offered_for_adoption(db): + """Adding it would start a walk that can only fetch what is already public.""" + await _membership(db, campaign="c1", status="former_patron") + await _synced(db) + await db.commit() + + assert (await reconcile(db, platform="patreon"))["subscribed_not_tracked"] == [] + + +@pytest.mark.asyncio +async def test_an_unrecognised_status_is_still_offered(db): + """None is not False. Withholding a membership because this build has not + been taught its status word would hide a real subscription.""" + await _membership(db, campaign="c1", status="a_word_nobody_characterised") + await _synced(db) + await db.commit() + + out = await reconcile(db, platform="patreon") + assert len(out["subscribed_not_tracked"]) == 1 + assert out["subscribed_not_tracked"][0]["paid_access"] is None + + +@pytest.mark.asyncio +async def test_a_free_follow_is_not_offered_as_a_subscription(db): + """`is_free_member` is a second axis: a CURRENT membership nobody pays for + is not a subscription to adopt.""" + await _membership( + db, campaign="c1", status="active_patron", + details={"campaign": {"vanity": "maewix"}, "is_free_member": True}, + ) + await _synced(db) + await db.commit() + + assert (await reconcile(db, platform="patreon"))["subscribed_not_tracked"] == [] + + +# --- the all-platforms payload --------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_platform_whose_sweep_never_succeeded_still_appears(db): + """Deriving the platform list from memberships alone would drop exactly the + platform whose credential is broken — making it indistinguishable from one + FC was never asked about.""" + db.add(MembershipSync(platform="patreon", last_error_type="PatreonAuthError")) + await db.commit() + + out = await reconcile_all(db) + assert [p["platform"] for p in out["platforms"]] == ["patreon"] + assert out["platforms"][0]["fresh"] is False