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