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
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""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
|