"""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