Files
FabledCurator/tests/roster_builders.py
T
bvandeusenandClaude Opus 5 aa765f0a72
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m47s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
feat: say why the posts are invisible, without ever deciding they are (387 C5)
A3 made a tier-gated source say "47 posts you can't see". The roster turns that into a reason: the membership ended, or the tier doesn't reach these posts, or it's a free follow. Rendered under A3's count in the health tooltip, quieter than the count it explains.

FREE is a fourth case the step didn't enumerate, and it earns its own sentence. has_paid_access collapses "former patron" and "current free follower" to the same False, so deriving the reason from that boolean would tell a free follower "you're not a patron any more" - a false statement about a state they were never in. gated_reason reads the status axis first, calling has_paid_access with is_free_member forced off, then splits on the free flag.

Silence is the default, and there are four ways into it: campaign absent from the roster, roster stale, platform never swept, status word not yet characterised. All four send null and the count stands alone. The frontend has no fallback sentence either - a default would turn "we don't know why" into a reason, which is the one thing this step must not do.

The line that must not be crossed is pinned structurally rather than by inspection: test_no_fetch_path_can_read_the_roster walks the transitive first-party imports from the fetch roots and asserts the roster is unreachable. FC runs no local verification (rule 85), so a guard cannot be falsified by hand before it lands - it carries two positive controls instead, proving the walker finds roster imports that ARE there, one direct and one through a hop, so the real assertion can never pass merely because the walk resolved nothing.

C4's identity loop moved to membership_roster.pair_sources_with_memberships when C5 became its second caller; two copies would let the Subscriptions row and the reconciliation card disagree about which creator a source IS. Three test files were each building PlatformMembership rows with their own drifting helper - consolidated into tests/roster_builders.py, same family as issue 3109.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
2026-09-11 22:59:29 -04:00

65 lines
2.6 KiB
Python

"""Row builders for the learned membership roster (#387 phase C).
Three test files were each constructing `PlatformMembership` and
`MembershipSync` rows with their own private helper — C4's reconcile tests,
E4's suggestion tests, and C5's gated-reason tests — and the three had already
started to drift apart in which fields they defaulted. That matters more here
than for ordinary test plumbing: every one of these tests turns on the exact
shape of a membership row (a `details["campaign"]["vanity"]` that the identity
join reads, an `is_free_member` flag that changes what the operator is told),
so three builders means three slightly different ideas of what a membership
looks like, and a test that passes against a row the sweep would never write.
`campaign=` rather than the column's own `external_campaign_id=`: it is what
the majority of call sites already say, and the full name earns nothing in a
builder whose only subject is memberships.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from backend.app.models import MembershipSync, PlatformMembership
# The shape a real Patreon sweep writes, per the C0 capture (Scribe note
# #3886): a vanity nested under `details.campaign`, which is where
# `PlatformMembership.vanity_or_none` reads it from.
DEFAULT_VANITY = "maewix"
DEFAULT_URL = f"https://www.patreon.com/{DEFAULT_VANITY}"
async def membership(
db, *, campaign="c1", platform="patreon", status="active_patron",
display_name="Maewix", url=DEFAULT_URL, details=None, **kw,
) -> PlatformMembership:
"""One observed membership.
`details` defaults to the vanity-bearing shape rather than to `{}`, because
a row with no vanity cannot be matched by handle and would quietly make
every identity test a campaign-id test.
"""
m = PlatformMembership(
platform=platform,
external_campaign_id=campaign,
status=status,
display_name=display_name,
url=url,
details={"campaign": {"vanity": DEFAULT_VANITY}} if details is None else details,
**kw,
)
db.add(m)
await db.flush()
return m
async def synced(db, *, platform="patreon", ago=timedelta(hours=1)) -> MembershipSync:
"""A successful sweep this recently — what makes a roster FRESH.
Pass `ago` past `ROSTER_STALE_AFTER` to build the stale case; omit the call
entirely for never-synced. Those are three different states and every
consumer of the roster has to tell them apart.
"""
state = MembershipSync(platform=platform, last_success_at=datetime.now(UTC) - ago)
db.add(state)
await db.flush()
return state