feat: what you pay for, against what FC actually follows (387 C4)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s
Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration. The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169. The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -7,7 +7,9 @@ from ..extensions import get_session
|
|||||||
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
||||||
from ..services.artist_membership_service import ArtistMembershipService
|
from ..services.artist_membership_service import ArtistMembershipService
|
||||||
from ..services.artist_membership_service import rescan as membership_rescan
|
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.scheduler_service import active_platform_cooldowns, scheduler_status
|
||||||
from ..services.source_service import (
|
from ..services.source_service import (
|
||||||
KNOWN_PLATFORMS,
|
KNOWN_PLATFORMS,
|
||||||
@@ -386,3 +388,68 @@ async def rescan_membership_suggestions():
|
|||||||
result = await membership_rescan(session)
|
result = await membership_rescan(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify(result)
|
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
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from ..utils.slug import slugify
|
from ..utils.slug import slugify
|
||||||
from ..utils.text import html_to_plain
|
from ..utils.text import html_to_plain
|
||||||
|
from .membership_roster import source_for_membership
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -204,6 +205,14 @@ class ArtistMembershipService:
|
|||||||
membership = await self.session.get(PlatformMembership, membership_id)
|
membership = await self.session.get(PlatformMembership, membership_id)
|
||||||
if membership is None:
|
if membership is None:
|
||||||
return 0
|
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)
|
already = await self._decided(membership_id)
|
||||||
|
|
||||||
made = 0
|
made = 0
|
||||||
|
|||||||
@@ -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)
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import MembershipSync, PlatformMembership
|
from ..models import MembershipSync, PlatformMembership, Source
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -275,3 +275,96 @@ async def sync_platform(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
log.info("membership sync ok for %s: %d membership(s)", platform, len(memberships))
|
log.info("membership sync ok for %s: %d membership(s)", platform, len(memberships))
|
||||||
return {"platform": platform, "ok": True, "count": 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 `<platform>_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
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Renders nothing when there is nothing to say — same posture as
|
||||||
|
NeedsAttentionCard. A reconciliation card that always shows would train
|
||||||
|
the operator to scroll past it. -->
|
||||||
|
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon">
|
||||||
|
<v-card-text>
|
||||||
|
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
|
||||||
|
<!-- Bucket 1: the adoption win. The only direction with an action,
|
||||||
|
because adding a source is the reversible half. -->
|
||||||
|
<template v-if="p.subscribed_not_tracked.length">
|
||||||
|
<div class="fc-recon__head">
|
||||||
|
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
|
||||||
|
<strong>
|
||||||
|
{{ p.subscribed_not_tracked.length }}
|
||||||
|
{{ p.platform }}
|
||||||
|
{{ p.subscribed_not_tracked.length === 1 ? 'creator' : 'creators' }}
|
||||||
|
you subscribe to but don't follow here
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div v-for="m in p.subscribed_not_tracked" :key="m.id" class="fc-recon__row">
|
||||||
|
<div class="fc-recon__body">
|
||||||
|
<strong>{{ m.display_name }}</strong>
|
||||||
|
<span v-if="m.tier_names?.length" class="fc-recon__dim">
|
||||||
|
· {{ m.tier_names.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<!-- A membership whose status this build has not been taught is
|
||||||
|
still offered, and says so rather than being presented as a
|
||||||
|
confirmed active subscription. -->
|
||||||
|
<span v-if="m.paid_access === null" class="fc-recon__dim">
|
||||||
|
· status not recognised ({{ m.status }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<v-btn size="small" variant="tonal" @click="store.adopt(m.id)">
|
||||||
|
Add subscription
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the
|
||||||
|
source list on this same page is where that decision belongs. -->
|
||||||
|
<template v-if="p.tracked_not_subscribed.length">
|
||||||
|
<div class="fc-recon__head">
|
||||||
|
<v-icon icon="mdi-help-circle-outline" size="small" class="me-2" />
|
||||||
|
<strong>
|
||||||
|
{{ p.tracked_not_subscribed.length }}
|
||||||
|
{{ p.platform }}
|
||||||
|
{{ p.tracked_not_subscribed.length === 1 ? 'source' : 'sources' }}
|
||||||
|
your roster doesn't account for
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div v-for="s in p.tracked_not_subscribed" :key="s.id" class="fc-recon__row">
|
||||||
|
<div class="fc-recon__body">
|
||||||
|
<strong>{{ s.artist.name }}</strong>
|
||||||
|
<div class="fc-recon__dim">{{ reasonFor(s) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fc-recon__dim fc-recon__note">
|
||||||
|
Nothing has been changed. If you want one of these to stop checking,
|
||||||
|
disable it in the list below.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- The roster could not be trusted, so bucket 2 was not computed. Said
|
||||||
|
in words: an empty list here must never read as "all clear". -->
|
||||||
|
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
|
||||||
|
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
|
||||||
|
<template v-if="!p.last_success_at">
|
||||||
|
Your {{ p.platform }} roster has never synced, so FC can't tell which
|
||||||
|
sources you still subscribe to.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Your {{ p.platform }} roster last synced
|
||||||
|
{{ formatRelative(p.last_success_at) }}, which is too old to draw
|
||||||
|
conclusions from.
|
||||||
|
</template>
|
||||||
|
Sync it from Settings → Subscription roster.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
import { formatRelative } from '../../utils/date.js'
|
||||||
|
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
|
||||||
|
|
||||||
|
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't
|
||||||
|
// follow get a one-click add, while sources the roster doesn't account for are
|
||||||
|
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point —
|
||||||
|
// adding a source is trivially undone, and "you no longer subscribe to this" is
|
||||||
|
// computed from an absence that has three possible causes.
|
||||||
|
const store = useMembershipReconcileStore()
|
||||||
|
|
||||||
|
// An untrustworthy roster is only worth mentioning when there is something it
|
||||||
|
// would have reconciled — otherwise a failed sweep on a platform with no
|
||||||
|
// sources yet would put a warning on a page with nothing to warn about.
|
||||||
|
const interesting = computed(() => store.platforms.filter(
|
||||||
|
p => p.subscribed_not_tracked.length
|
||||||
|
|| p.tracked_not_subscribed.length
|
||||||
|
|| (!p.fresh && p.tracked_total)
|
||||||
|
))
|
||||||
|
const anythingToSay = computed(() => interesting.value.length > 0)
|
||||||
|
|
||||||
|
function reasonFor (s) {
|
||||||
|
if (s.basis === 'lapsed') {
|
||||||
|
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.`
|
||||||
|
}
|
||||||
|
if (s.basis === 'absent_exact') {
|
||||||
|
return "FC knows this creator's id on the platform, and it isn't in your roster."
|
||||||
|
}
|
||||||
|
// absent_handle — the weakest claim, and it says so. A renamed creator looks
|
||||||
|
// exactly like this, which is why it is not phrased as a conclusion.
|
||||||
|
return "No membership matched this source's address. It may simply have been renamed."
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { store.load() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-recon__platform + .fc-recon__platform {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.fc-recon__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.fc-recon__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
}
|
||||||
|
.fc-recon__body { flex: 1; min-width: 0; }
|
||||||
|
/* Rule 96: dim text is the theme token, never bare opacity. */
|
||||||
|
.fc-recon__dim {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.fc-recon__note { margin-top: 8px; }
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
nothing to say. -->
|
nothing to say. -->
|
||||||
<NeedsAttentionCard />
|
<NeedsAttentionCard />
|
||||||
<RecentArrivalsCard />
|
<RecentArrivalsCard />
|
||||||
|
<!-- #387 C4: what you pay for vs. what FC follows. Renders nothing when
|
||||||
|
the two agree. -->
|
||||||
|
<MembershipReconcileCard />
|
||||||
|
|
||||||
<div class="fc-subs__bar">
|
<div class="fc-subs__bar">
|
||||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||||
@@ -327,6 +330,7 @@ import { usePlatformsStore } from '../../stores/platforms.js'
|
|||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
import NeedsAttentionCard from './NeedsAttentionCard.vue'
|
import NeedsAttentionCard from './NeedsAttentionCard.vue'
|
||||||
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
||||||
|
import MembershipReconcileCard from './MembershipReconcileCard.vue'
|
||||||
import SourceRow from './SourceRow.vue'
|
import SourceRow from './SourceRow.vue'
|
||||||
import SourceCard from './SourceCard.vue'
|
import SourceCard from './SourceCard.vue'
|
||||||
import SourceHealthDot from './SourceHealthDot.vue'
|
import SourceHealthDot from './SourceHealthDot.vue'
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_an_artist_with_no_sources_at_all_is_never_proposed(db):
|
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
|
"""Not a creator FC follows through another channel, which is the whole
|
||||||
|
|||||||
@@ -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/<vanity> and /cw/<vanity> (#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
|
||||||
Reference in New Issue
Block a user