"""SubscribeStarClient.iter_memberships — parsing only, no network (#387 D1). The fixture mirrors a REAL capture of the operator's /subscriptions page (Scribe note #3989) with every value invented. Its structure was checked against the capture tag for tag before this file was written: the card wrappers, both table heads, and every distinct row shape — including the two forms the Discord cell takes, and the narrow-screen `` that active rows nest INSIDE their own row. Most of what follows pins refusals. SubscribeStar gives no completeness signal the way Patreon's `meta.pagination.total` does, and every conclusion drawn from this roster is drawn from absence, so the parser's job is to raise whenever it cannot vouch for the whole list. `_get` is stubbed rather than mocked at the socket: these tests are about what the client does with a page, and the HTTP path is covered by test_subscribestar_client.py. """ from pathlib import Path from types import SimpleNamespace import pytest from backend.app.services.membership_roster import has_paid_access, roster_user_id from backend.app.services.native_ingest_common import Membership from backend.app.services.subscribestar_client import ( SubscribeStarAuthError, SubscribeStarClient, SubscribeStarDriftError, parse_subscriptions_page, ) _FIXTURE = Path(__file__).parent / "fixtures" / "subscribestar_subscriptions_page1.html" @pytest.fixture def page(): return _FIXTURE.read_text() @pytest.fixture def client(): return SubscribeStarClient(cookies_path=None) def _serve(client, html): calls = [] def fake(url, *, headers=None): calls.append(url) return SimpleNamespace(text=html) client._get = fake return calls def _by_id(memberships): return {m.campaign_id: m for m in memberships} # --- the request ----------------------------------------------------------- def test_the_roster_is_fetched_from_adult_not_art(client, page): """The capture came from `.art`, but FC's requests never clear the `.art` age wall with the 18+ cookie (#1259, #1284). Copying the browser's host is the obvious move and would turn every sweep into an auth error.""" calls = _serve(client, page) list(client.iter_memberships()) assert calls == ["https://subscribestar.adult/subscriptions"] def test_one_request_for_the_whole_roster(client, page): calls = _serve(client, page) list(client.iter_memberships(user_id="ignored")) assert len(calls) == 1 # --- what a membership contains -------------------------------------------- def test_both_tables_are_read(page): rows = parse_subscriptions_page(page) assert all(isinstance(m, Membership) for m in rows) assert len(rows) == 4 statuses = sorted(m.status for m in rows) assert statuses == [ "active_subscriptions", "active_subscriptions", "cancelled_subscriptions", "cancelled_subscriptions", ] def test_the_creator_id_is_the_key_not_the_slug(page): """A slug re-keys when a creator renames; the old row then stops appearing, and reconciliation reads a disappearance as a lapse. The numeric id does not change, so a rename can never surface as "you cancelled this creator" (note #3989, CORRECTION 2).""" rows = _by_id(parse_subscriptions_page(page)) assert set(rows) == {"1001", "2002002", "3003003", "4004"} alpha = rows["1001"] assert alpha.vanity == "creator-alpha" # The handle is carried as a URL so `match_kind`'s URL-tail fallback reaches # it — which is what a SubscribeStar Source.url ends in. assert alpha.url == "https://subscribestar.adult/creator-alpha" assert alpha.display_name == "Creator Alpha" def test_the_table_is_the_status(page): """No per-row status word exists. The card's `data-identifier` is stored verbatim — and is exactly what MEMBERSHIP_STATUS maps, so a renamed identifier fails here rather than silently reading as unknown.""" rows = _by_id(parse_subscriptions_page(page)) assert rows["1001"].status == "active_subscriptions" assert rows["3003003"].status == "cancelled_subscriptions" assert has_paid_access("subscribestar", rows["1001"].status) is True assert has_paid_access("subscribestar", rows["3003003"].status) is False def test_price_is_kept_as_text_and_never_parsed_into_money(page): """A bare `$` names no currency and a page price is not proven to be the charge. None keeps "unknown" distinct from zero (#3970 finding 4).""" rows = _by_id(parse_subscriptions_page(page)) alpha = rows["1001"] assert alpha.amount_cents is None assert alpha.currency is None assert alpha.details["columns"]["price"] == "$5" assert alpha.tier_names == [] assert alpha.is_free_member is False def test_columns_are_paired_by_position_not_by_class(page): """Two active columns share the `for-date` class, and the updates column's does not carry its 's class — pairing by class would merge or drop them. Controls and identity stay out of details.""" cols = _by_id(parse_subscriptions_page(page))["1001"].details["columns"] assert cols == { "subscribed": "Jan 2026", "renewed": "Mar 2026", "paused": "-", "price": "$5", "discord": "Joined", } cancelled = _by_id(parse_subscriptions_page(page))["3003003"].details["columns"] assert cancelled == {"unsubscribed": "Dec 2025", "price": "$3"} def test_both_discord_cell_forms_read_as_text(page): """The real page renders a linked Discord as a button with an icon, and an unlinked one as the ENTITY `—`. Undecoded, the second would store the literal string "—".""" rows = _by_id(parse_subscriptions_page(page)) assert rows["1001"].details["columns"]["discord"] == "Joined" assert rows["2002002"].details["columns"]["discord"] == "—" def test_the_nested_actions_row_is_not_read_as_columns(page): """Active rows nest a narrow-screen `` inside their own row. Its cells would otherwise zip onto the header as extra columns.""" cols = _by_id(parse_subscriptions_page(page))["2002002"].details["columns"] assert "Manage options" not in cols.values() assert set(cols) == {"subscribed", "renewed", "paused", "price", "discord"} # --- refusals -------------------------------------------------------------- def test_a_creator_in_both_tables_is_reported_once_as_active(page): """A current subscription is the fact that matters. Written the other way round, the cancelled row would overwrite the active one in the upsert and tell a paying subscriber they had lapsed.""" duplicated = page.replace('data-user-id="3003003"', 'data-user-id="1001"') rows = parse_subscriptions_page(duplicated) matches = [m for m in rows if m.campaign_id == "1001"] assert len(matches) == 1 assert matches[0].status == "active_subscriptions" def test_an_account_that_never_cancelled_has_no_cancelled_card(page): """Not drift — such an account plausibly renders no cancelled table.""" start = page.index('
", start) + len("") rows = parse_subscriptions_page(page[:start] + page[end:]) assert {m.status for m in rows} == {"active_subscriptions"} def test_a_login_wall_is_auth_not_drift(): """The fix for this is a fresh credential, not a scraper change — and the distinction survives into membership_sync.last_error_type, where it is what makes the UI's advice correct.""" wall = '
' with pytest.raises(SubscribeStarAuthError): parse_subscriptions_page(wall) def test_an_unrecognised_page_is_drift(): with pytest.raises(SubscribeStarDriftError): parse_subscriptions_page("Something else") def test_a_row_without_a_creator_id_refuses_the_whole_roster(page): """Identity is the point (#3970 §4). Skipping the row instead would return a roster one creator short, which downstream reads as a cancellation.""" with pytest.raises(SubscribeStarDriftError, match="data-user-id"): parse_subscriptions_page(page.replace(' data-user-id="2002002"', "")) def test_a_non_numeric_creator_id_is_drift(page): with pytest.raises(SubscribeStarDriftError, match="data-user-id"): parse_subscriptions_page(page.replace('data-user-id="2002002"', 'data-user-id="beta"')) def test_a_row_without_a_creator_link_is_drift(page): with pytest.raises(SubscribeStarDriftError, match="creator link"): parse_subscriptions_page(page.replace('', "")) @pytest.mark.parametrize("card", ["active_subscriptions", "cancelled_subscriptions"]) def test_content_after_a_table_is_treated_as_possible_pagination(page, card): """Both cards are paginatable (`app#embed_pagination`), and the capture was too short to show what pagination looks like. Anything rendered after a table is therefore a roster FC cannot prove complete — raised, never returned short.""" start = page.index(f'data-identifier="{card}"') close = page.index("", start) + len("") paginated = page[:close] + '' + page[close:] with pytest.raises(SubscribeStarDriftError, match="cannot be proven complete"): parse_subscriptions_page(paginated) def test_a_page_link_anywhere_in_the_roster_is_drift(page): """The same concern for a paginator rendered outside the card.""" paged = page.replace("", 'Next') with pytest.raises(SubscribeStarDriftError, match="page="): parse_subscriptions_page(paged) def test_a_drift_error_mid_page_yields_nothing(client, page): """The whole page is parsed before anything is yielded, so a caller can never be left holding the rows that came before the bad one.""" _serve(client, page.replace(' data-user-id="4004"', "")) got = [] with pytest.raises(SubscribeStarDriftError): for m in client.iter_memberships(): got.append(m) assert got == [] # --- the sweep's user-id probe --------------------------------------------- def test_a_client_without_current_user_id_needs_no_id(): """The seam leak D1 found: the sweep called `current_user_id()` on every client, which exists only on Patreon's. Note #3970 promised a second platform would be one `builders` line and the client method; without this probe it would have raised AttributeError on the first sweep.""" assert roster_user_id(SubscribeStarClient(cookies_path=None)) is None def test_a_client_with_current_user_id_is_asked_for_it(): assert roster_user_id(SimpleNamespace(current_user_id=lambda: "248453")) == "248453"