CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
The second platform through the seam note 3970 contracted, characterized first from a live capture of the account's /subscriptions page (note 3989). The capture lives in the gitignored captures dir; the committed fixture is hand-built with invented values and was verified tag-for-tag against it - card wrappers, both table heads, and every distinct row shape - before any code depended on it. What the page is, and the three decisions it forced: The table IS the status. SubscribeStar has no per-row status word: a creator is either in the active_subscriptions card or the cancelled_subscriptions one. The card's data-identifier is stored verbatim as Membership.status and mapped in MEMBERSHIP_STATUS, keyed on the identifier rather than the table class because the cancelled table's class names the same list differently (for-unsubscribed_users). The creator's numeric data-user-id is the key, not the slug. A slug re-keys when a creator renames; the old row stops appearing; and a disappearance is exactly what reconciliation reads as a lapse. Keyed on the slug, a rename would have told a paying subscriber they had cancelled. The slug rides as vanity, where the identity join already looks for a handle. Price is kept as text, never parsed into amount_cents. A bare $ names no currency and a page price is not proven to be the charge - 3970 finding 4. Tier names live behind a per-row modal and are not fetched. Refusals, because SubscribeStar offers nothing like Patreon's meta.pagination.total and every conclusion downstream is drawn from absence. The parser raises when: the active card is missing (auth error on a login/age wall, drift otherwise); a row lacks a numeric creator id or a creator link; anything renders after a card's table; or the page carries a page= link. Both cards are paginatable (app#embed_pagination) and the captured account was too small to show what pagination looks like, so possible pagination is a roster FC cannot prove complete. A loud error on a larger account beats a quiet half-list. A missing cancelled card is not drift, and a creator in both tables is reported once, as active. Fetched from subscribestar.adult, not the .art the capture came from: FC's requests never clear the .art age wall with the 18+ cookie (1259, 1284). Whether /subscriptions on .adult authenticates exactly as .art did in the browser is untested - if not, the sweep records a visible error and C6 shows its unavailable rung. The seam leak D1 found. Note 3970 promised a second platform would be one builders line plus the client method. The sweep instead called current_user_id() on every client, which only Patreon's has, so SubscribeStar would have raised AttributeError on the first sweep. roster_user_id probes it with getattr, the same way the sweep already probes iter_memberships. Two existing tests were passing for the wrong reason and now can fail: - "a platform that has never been characterised says nothing" named SubscribeStar, and stayed green only because active_patron is not a SubscribeStar word. Now uses hentaifoundry, with a positive SubscribeStar test beside it. - the freshness test gave SubscribeStar a Patreon word, so the vocabulary excluded it and deleting the freshness gate outright would have left it green. It now uses cancelled_subscriptions, making the gate the only thing that excludes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""Milestone 387 C5: why the posts are invisible, in the roster's own words.
|
|
|
|
A3 gave a tier-gated source a count — "47 posts you can't see". The roster can
|
|
turn that into a reason, and the whole risk of this step is that a reason is a
|
|
much stronger claim than a count. So most of what follows pins refusals:
|
|
|
|
* an unrecognised status says NOTHING rather than guessing lapsed;
|
|
* a campaign absent from the roster says nothing, because absence is not
|
|
evidence (the same discipline as `test_post_is_gated_only_on_explicit_false`);
|
|
* a stale roster degrades to the bare count rather than asserting last week's
|
|
reason as today's;
|
|
* a current FREE follower is not told they used to be a patron.
|
|
|
|
And the one that matters most: no fetch path can read the roster at all. That
|
|
is asserted structurally, not by inspection — the roster explains a skip that
|
|
already happened, and must never be able to cause one.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.app.models import Artist, Source
|
|
from backend.app.services.gallery_dl import ErrorType
|
|
from backend.app.services.membership_roster import (
|
|
GATED_FREE,
|
|
GATED_LAPSED,
|
|
GATED_TIER,
|
|
ROSTER_STALE_AFTER,
|
|
gated_reason,
|
|
gated_reasons_for_sources,
|
|
)
|
|
from tests.roster_builders import membership as _membership
|
|
from tests.roster_builders import synced as _synced
|
|
|
|
# --- the pure mapping: status word -> the words we are entitled to say -------
|
|
#
|
|
# No database, so these run in the unit lane. `gated_reason` is where the claim
|
|
# is actually decided; everything below it is plumbing.
|
|
|
|
|
|
def test_a_former_patron_is_told_the_membership_ended():
|
|
assert gated_reason("patreon", "former_patron") == GATED_LAPSED
|
|
|
|
|
|
def test_an_active_patron_is_told_the_tier_does_not_reach_these_posts():
|
|
assert gated_reason("patreon", "active_patron") == GATED_TIER
|
|
|
|
|
|
def test_a_current_free_follower_is_not_told_they_used_to_be_a_patron():
|
|
"""`free` and `lapsed` are different states and must not share a sentence.
|
|
|
|
Both make `has_paid_access` False, which is why the reason is derived from
|
|
the status axis first rather than from that boolean: "you're not a patron
|
|
any more" is a false statement about someone who never was one.
|
|
"""
|
|
assert gated_reason("patreon", "active_patron", is_free_member=True) == GATED_FREE
|
|
assert gated_reason("patreon", "former_patron", is_free_member=True) == GATED_LAPSED
|
|
|
|
|
|
@pytest.mark.parametrize("status", [None, "declined_patron", "", "wat"])
|
|
def test_an_unrecognised_status_says_nothing(status):
|
|
"""Unknown is not lapsed.
|
|
|
|
`declined_patron` is in here on purpose: it is the plausible-looking word
|
|
the C0 capture proved is NOT in the `patron_status` vocabulary. If someone
|
|
adds it to MEMBERSHIP_STATUS on the strength of the request filter, this
|
|
test is where that shows up.
|
|
"""
|
|
assert gated_reason("patreon", status) is None
|
|
|
|
|
|
def test_a_platform_that_has_never_been_characterised_says_nothing():
|
|
"""An uncharacterised platform inherits silence, not a Patreon guess.
|
|
|
|
This named SubscribeStar until D1 characterised it (note #3989). Left as it
|
|
was, it would have kept passing only because `active_patron` is not a
|
|
SubscribeStar word, testing nothing its name claims.
|
|
"""
|
|
assert gated_reason("hentaifoundry", "active_patron") is None
|
|
|
|
|
|
def test_subscribestar_explains_the_gate_in_its_own_words():
|
|
"""SubscribeStar's status is which table the creator sits in, and those
|
|
identifiers reach the same three reasons as Patreon's words."""
|
|
assert gated_reason("subscribestar", "active_subscriptions") == GATED_TIER
|
|
assert gated_reason("subscribestar", "cancelled_subscriptions") == GATED_LAPSED
|
|
# Patreon's vocabulary does not leak across platforms.
|
|
assert gated_reason("subscribestar", "former_patron") is None
|
|
|
|
|
|
# --- the join, against the database ----------------------------------------
|
|
|
|
|
|
async def _artist(db, name="Maewix"):
|
|
a = Artist(name=name, slug=name.lower().replace(" ", ""))
|
|
db.add(a)
|
|
await db.flush()
|
|
return a
|
|
|
|
|
|
async def _gated_source(db, artist, *, url="https://www.patreon.com/maewix",
|
|
platform="patreon", overrides=None):
|
|
s = Source(
|
|
artist_id=artist.id, platform=platform, url=url, enabled=True,
|
|
error_type=ErrorType.TIER_LIMITED, config_overrides=overrides,
|
|
)
|
|
db.add(s)
|
|
await db.flush()
|
|
return s
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_a_matched_active_membership_explains_the_gate(db):
|
|
artist = await _artist(db)
|
|
source = await _gated_source(db, artist)
|
|
await _membership(db)
|
|
await _synced(db)
|
|
|
|
assert await gated_reasons_for_sources(db, [source]) == {source.id: GATED_TIER}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_a_lapsed_membership_explains_the_gate(db):
|
|
artist = await _artist(db)
|
|
source = await _gated_source(db, artist)
|
|
await _membership(db, status="former_patron")
|
|
await _synced(db)
|
|
|
|
assert await gated_reasons_for_sources(db, [source]) == {source.id: GATED_LAPSED}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_a_campaign_absent_from_the_roster_adds_no_words(db):
|
|
"""The third case in the step: absence is not evidence.
|
|
|
|
The sweep succeeded an hour ago and this creator is simply not in it. That
|
|
could mean the subscription lapsed — or that the sweep's pagination is
|
|
incomplete, or that the creator renamed. The count stands alone.
|
|
"""
|
|
artist = await _artist(db)
|
|
source = await _gated_source(db, artist)
|
|
await _membership(db, campaign="somebody-else",
|
|
url="https://www.patreon.com/someoneelse",
|
|
details={"campaign": {"vanity": "someoneelse"}})
|
|
await _synced(db)
|
|
|
|
assert await gated_reasons_for_sources(db, [source]) == {}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_a_stale_roster_degrades_to_the_bare_count(db):
|
|
"""Same data as the passing case, one stale sync state.
|
|
|
|
A reason is a claim about NOW. Last week's roster cannot make it.
|
|
"""
|
|
artist = await _artist(db)
|
|
source = await _gated_source(db, artist)
|
|
await _membership(db, status="former_patron")
|
|
await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(hours=1))
|
|
|
|
assert await gated_reasons_for_sources(db, [source]) == {}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_a_never_synced_platform_degrades_to_the_bare_count(db):
|
|
artist = await _artist(db)
|
|
source = await _gated_source(db, artist)
|
|
await _membership(db, status="former_patron")
|
|
# No MembershipSync row at all.
|
|
|
|
assert await gated_reasons_for_sources(db, [source]) == {}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_one_platforms_fresh_sweep_does_not_vouch_for_anothers(db):
|
|
"""Freshness is per platform, so a working Patreon sweep cannot lend its
|
|
credibility to a SubscribeStar roster that has never run (D1's future)."""
|
|
artist = await _artist(db)
|
|
patreon = await _gated_source(db, artist)
|
|
other = await _gated_source(
|
|
db, artist, platform="subscribestar",
|
|
url="https://subscribestar.adult/maewix",
|
|
)
|
|
await _membership(db, status="former_patron")
|
|
# SubscribeStar's REAL word, so that the freshness gate is the only thing
|
|
# excluding this membership. With a Patreon word (as this test first had),
|
|
# the vocabulary excluded it and removing the gate would have left it green.
|
|
await _membership(db, platform="subscribestar", campaign="s1",
|
|
status="cancelled_subscriptions",
|
|
url="https://subscribestar.adult/maewix",
|
|
details={"campaign": {"vanity": "maewix"}})
|
|
await _synced(db) # patreon only
|
|
|
|
reasons = await gated_reasons_for_sources(db, [patreon, other])
|
|
assert reasons == {patreon.id: GATED_LAPSED}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_the_cached_campaign_id_wins_over_a_stale_url(db):
|
|
"""Inherited from `match_kind` via the shared join — asserted here so C5
|
|
keeps identity in step with C4 rather than quietly growing its own."""
|
|
artist = await _artist(db)
|
|
source = await _gated_source(
|
|
db, artist, url="https://www.patreon.com/old-handle",
|
|
overrides={"patreon_campaign_id": "c1"},
|
|
)
|
|
await _membership(db, campaign="c1", url="https://www.patreon.com/new-handle",
|
|
details={"campaign": {"vanity": "new-handle"}},
|
|
status="former_patron")
|
|
await _synced(db)
|
|
|
|
assert await gated_reasons_for_sources(db, [source]) == {source.id: GATED_LAPSED}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_no_sources_asks_the_database_nothing(db):
|
|
assert await gated_reasons_for_sources(db, []) == {}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_the_source_list_carries_the_reason_to_the_ui(db):
|
|
"""End to end through `SourceService.list`, because the field being on the
|
|
record is not the same as the field reaching the payload."""
|
|
from backend.app.services.source_service import SourceService
|
|
|
|
artist = await _artist(db)
|
|
source = await _gated_source(db, artist)
|
|
await _membership(db, status="former_patron")
|
|
await _synced(db)
|
|
await db.commit()
|
|
|
|
records = await SourceService(db).list()
|
|
row = next(r.to_dict() for r in records if r.id == source.id)
|
|
assert row["gated_reason"] == GATED_LAPSED
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_a_source_that_is_not_gated_gets_no_reason(db):
|
|
"""The roster annotates a gated state; it never puts one on a healthy row.
|
|
|
|
A source walking fine whose creator the operator stopped paying for has
|
|
nothing to explain — and saying "you're not a patron any more" beside a
|
|
healthy row would be the roster asserting inaccessibility on its own.
|
|
"""
|
|
from backend.app.services.source_service import SourceService
|
|
|
|
artist = await _artist(db)
|
|
source = Source(
|
|
artist_id=artist.id, platform="patreon", enabled=True,
|
|
url="https://www.patreon.com/maewix", error_type=None,
|
|
)
|
|
db.add(source)
|
|
await db.flush()
|
|
await _membership(db, status="former_patron")
|
|
await _synced(db)
|
|
await db.commit()
|
|
|
|
records = await SourceService(db).list()
|
|
row = next(r.to_dict() for r in records if r.id == source.id)
|
|
assert row["gated_reason"] is None
|
|
assert row["tier_gated_count"] is None
|
|
|
|
|
|
# --- the line that must not be crossed --------------------------------------
|
|
|
|
_APP = Path(__file__).resolve().parents[1] / "backend" / "app"
|
|
|
|
# The modules that FETCH: the walk, the downloaders, the per-platform clients
|
|
# and ingesters. Named as ROOTS of an import walk rather than as the place the
|
|
# assertion looks — the check follows their transitive first-party imports, so
|
|
# it still holds when the logic inside them moves (rule #167).
|
|
_FETCH_ROOTS = [
|
|
"services/download_service.py",
|
|
"services/ingest_core.py",
|
|
"services/native_ingest_common.py",
|
|
"services/gallery_dl.py",
|
|
"services/refetch_service.py",
|
|
]
|
|
|
|
# Reading ANY of these is reading the roster.
|
|
_ROSTER_MODULES = {"services.membership_roster", "services.membership_reconcile"}
|
|
|
|
|
|
def _first_party_imports(path: Path) -> set[str]:
|
|
"""Every `backend.app.*` module this file imports, as a dotted path
|
|
relative to `backend/app` — absolute and relative forms both."""
|
|
tree = ast.parse(path.read_text())
|
|
# The dotted parts of the package CONTAINING this module. `parts[:-1]` is
|
|
# right for both forms without a special case: `services/foo.py` drops
|
|
# `foo` to leave `services`, and `api/__init__.py` drops `__init__` to
|
|
# leave `api` — which is exactly what `from .` means inside each.
|
|
pkg = path.relative_to(_APP).with_suffix("").parts[:-1]
|
|
out: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
if node.level:
|
|
# `from ..models import X` -> walk up from the containing
|
|
# package. Clamped at 0: a level that climbs past `backend.app`
|
|
# leaves this tree, and an unclamped negative index would wrap
|
|
# and silently resolve to the wrong module.
|
|
up = max(len(pkg) - (node.level - 1), 0)
|
|
mod = list(pkg[:up]) + (node.module.split(".") if node.module else [])
|
|
elif node.module and node.module.startswith("backend.app."):
|
|
mod = node.module[len("backend.app."):].split(".")
|
|
else:
|
|
continue
|
|
if not mod:
|
|
# `from . import x` in a module sitting directly under
|
|
# `backend/app` (celery_app.py does this): the package is the
|
|
# app root, so the alias alone is the module's dotted name.
|
|
for alias in node.names:
|
|
out.add(alias.name)
|
|
continue
|
|
out.add(".".join(mod))
|
|
# `from .membership_roster import x` and
|
|
# `from . import membership_roster` must both resolve to the module.
|
|
for alias in node.names:
|
|
out.add(".".join([*mod, alias.name]))
|
|
elif isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name.startswith("backend.app."):
|
|
out.add(alias.name[len("backend.app."):])
|
|
return out
|
|
|
|
|
|
def _reachable_from(roots: list[str]) -> set[str]:
|
|
seen: set[str] = set()
|
|
queue = [r[:-3].replace("/", ".") for r in roots]
|
|
while queue:
|
|
mod = queue.pop()
|
|
if mod in seen:
|
|
continue
|
|
seen.add(mod)
|
|
parts = [p for p in mod.split(".") if p]
|
|
if not parts:
|
|
continue
|
|
for candidate in (_APP.joinpath(*parts) / "__init__.py",
|
|
_APP.joinpath(*parts).with_suffix(".py")):
|
|
if candidate.is_file():
|
|
queue.extend(_first_party_imports(candidate) - seen)
|
|
break
|
|
return seen
|
|
|
|
|
|
def test_the_fetch_roots_all_exist():
|
|
"""Falsifiability guard for the guard below.
|
|
|
|
A reachability check passes trivially if its roots resolve to nothing, so a
|
|
renamed module would silently turn the real assertion into a no-op that
|
|
still reads as coverage.
|
|
"""
|
|
missing = [r for r in _FETCH_ROOTS if not (_APP / r).is_file()]
|
|
assert not missing, f"fetch roots moved or were renamed: {missing}"
|
|
|
|
|
|
# The falsifiability controls. FC runs no local verification (rule #85), so a
|
|
# guard cannot be falsified by hand before it is committed — it has to carry its
|
|
# own proof that it is capable of failing, and re-prove it on every run. These
|
|
# two assert the walker finds roster imports that ARE there, one direct and one
|
|
# through a hop, so the real assertion below can never pass merely because the
|
|
# walk resolved nothing.
|
|
|
|
|
|
def test_the_walk_sees_a_direct_roster_import():
|
|
"""`source_service` legitimately reads the roster — it is what annotates the
|
|
Subscriptions rows. If this stops holding the walker has gone blind, not the
|
|
dependency gone away."""
|
|
assert "services.membership_roster" in _reachable_from(["services/source_service.py"])
|
|
|
|
|
|
def test_the_walk_follows_more_than_one_hop():
|
|
"""api/sources.py -> services/source_service.py -> services/membership_roster.py.
|
|
|
|
A one-hop walker would pass the guard below on a fetch path that reaches the
|
|
roster through any intermediate module, which is the likeliest way this ever
|
|
actually regresses.
|
|
"""
|
|
reachable = _reachable_from(["api/sources.py"])
|
|
assert "services.source_service" in reachable
|
|
assert "services.membership_roster" in reachable
|
|
|
|
|
|
def test_no_fetch_path_can_read_the_roster():
|
|
"""The roster explains a skip. It must never be able to cause one.
|
|
|
|
Entitled-tier data says which tiers the account holds, not which posts
|
|
those tiers unlock — `current_user_can_view` is the only per-post truth. A
|
|
fetch path that could consult the roster could decide not to fetch
|
|
something the operator is paying for, silently, and that is the worst
|
|
failure available in this milestone.
|
|
|
|
Import reachability rather than a call-site scan: a module that cannot
|
|
reach the roster cannot consult it, and unlike a grep for a function name
|
|
this keeps holding when the functions are renamed.
|
|
"""
|
|
reachable = _reachable_from(_FETCH_ROOTS)
|
|
assert not (reachable & _ROSTER_MODULES), (
|
|
"a fetch path can now reach the membership roster: "
|
|
f"{sorted(reachable & _ROSTER_MODULES)}. The roster may annotate a "
|
|
"gated state, never produce one — see membership_roster.gated_reason."
|
|
)
|