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