Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b4e532c56 | ||
|
|
240f11c5aa | ||
|
|
57c880a623 | ||
|
|
dd766eb976 | ||
|
|
e3c516d6be |
@@ -47,12 +47,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
||||||
from .membership_roster import (
|
from .membership_roster import (
|
||||||
get_sync_state,
|
get_sync_state,
|
||||||
has_paid_access,
|
|
||||||
identity_keys_for_source,
|
identity_keys_for_source,
|
||||||
pair_sources_with_memberships,
|
pair_sources_with_memberships,
|
||||||
roster_is_fresh,
|
roster_is_fresh,
|
||||||
url_tail,
|
url_tail,
|
||||||
)
|
)
|
||||||
|
from .native_ingest_common import has_paid_access
|
||||||
|
|
||||||
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
||||||
# the UI renders a different sentence per basis, because collapsing them into
|
# the UI renders a different sentence per basis, because collapsing them into
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ own word — `active_patron`, not some normalised FC value. The mapping from
|
|||||||
those words to FC's meaning is a read-site concern and belongs in code that can
|
those words to FC's meaning is a read-site concern and belongs in code that can
|
||||||
be corrected without a migration, because the vocabulary comes from whatever
|
be corrected without a migration, because the vocabulary comes from whatever
|
||||||
each platform says and will be discovered per platform rather than designed up
|
each platform says and will be discovered per platform rather than designed up
|
||||||
front. `MEMBERSHIP_STATUS` below is a place for that knowledge to accumulate as
|
front. `native_ingest_common.MEMBERSHIP_STATUS` is where that knowledge
|
||||||
platforms are characterised; it is deliberately empty of guesses today.
|
accumulates as platforms are characterised, and it holds no guesses. It lives
|
||||||
|
there rather than here because platform clients need it, and a client may not
|
||||||
|
import this module (test_gated_reason.py).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,78 +37,10 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import MembershipSync, PlatformMembership, Source
|
from ..models import MembershipSync, PlatformMembership, Source
|
||||||
|
from .native_ingest_common import has_paid_access
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Platform word -> whether the account currently has paid access.
|
|
||||||
#
|
|
||||||
# Every entry here must come from a CHARACTERISED response, never from API docs
|
|
||||||
# or a plausible guess — project rule 130, and inventing a status before seeing
|
|
||||||
# it in a real payload is exactly the failure it names.
|
|
||||||
#
|
|
||||||
# patreon: from a live capture of the operator's own session, 2026-09-10
|
|
||||||
# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and
|
|
||||||
# only those two are here.
|
|
||||||
#
|
|
||||||
# `declined_patron` is deliberately ABSENT even though it looks obviously
|
|
||||||
# right. It appears in the request's `filter[membership_type]`, and the capture
|
|
||||||
# proved that filter is NOT the same vocabulary as the attribute — a row
|
|
||||||
# selected by the filter as `free_member` came back with
|
|
||||||
# `patron_status: former_patron`, a word the filter does not contain. Reading
|
|
||||||
# the filter as an enum is the specific mistake the capture caught; adding
|
|
||||||
# `declined_patron` on the strength of it would be repeating that mistake one
|
|
||||||
# step later.
|
|
||||||
#
|
|
||||||
# Unknown words are NOT an error: an unrecognised status means the roster
|
|
||||||
# records evidence it cannot yet interpret, which is a better state than
|
|
||||||
# dropping the row or asserting a meaning for it.
|
|
||||||
#
|
|
||||||
# subscribestar: from a live capture of the account's /subscriptions page,
|
|
||||||
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
|
|
||||||
# a membership's state is which of two tables it sits in — so the "word" stored
|
|
||||||
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
|
|
||||||
# the whole vocabulary; there is nothing further to characterise later.
|
|
||||||
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
|
|
||||||
"patreon": {
|
|
||||||
"active_patron": True,
|
|
||||||
"former_patron": False,
|
|
||||||
},
|
|
||||||
"subscribestar": {
|
|
||||||
"active_subscriptions": True,
|
|
||||||
"cancelled_subscriptions": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def has_paid_access(
|
|
||||||
platform: str, status: str | None, *, is_free_member: bool = False,
|
|
||||||
) -> bool | None:
|
|
||||||
"""Does this membership mean the account currently PAYS for access?
|
|
||||||
|
|
||||||
Returns None for a status this code has not been taught, which callers must
|
|
||||||
treat as "unknown" rather than as False. The difference matters: False says
|
|
||||||
the operator has lost access, and asserting that from an unrecognised word
|
|
||||||
would tell them to cancel a source they are still paying for.
|
|
||||||
|
|
||||||
`is_free_member` is a second axis, not a status, and that is Patreon's
|
|
||||||
design rather than ours: the capture shows a free follow expressed as a
|
|
||||||
boolean alongside `patron_status`, so a "current" membership can still be
|
|
||||||
one nobody is paying for. Taking status alone would report a free follower
|
|
||||||
as a paying patron, and C4 would then never offer to clean it up.
|
|
||||||
|
|
||||||
(Honest limit: the capture contains no ACTIVE free member, so it cannot
|
|
||||||
demonstrate the two axes coming apart. The separation is what the payload's
|
|
||||||
shape says; the sample only shows it is possible, not that it happens.)
|
|
||||||
"""
|
|
||||||
if status is None:
|
|
||||||
return None
|
|
||||||
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
|
|
||||||
if known is None:
|
|
||||||
return None
|
|
||||||
if not known:
|
|
||||||
return False
|
|
||||||
return not is_free_member
|
|
||||||
|
|
||||||
|
|
||||||
async def touch_membership(
|
async def touch_membership(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class Membership:
|
|||||||
|
|
||||||
`status` carries the PLATFORM's own word, verbatim and unmapped
|
`status` carries the PLATFORM's own word, verbatim and unmapped
|
||||||
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
|
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
|
||||||
site's job — `membership_roster.has_paid_access` — precisely so an
|
site's job — `has_paid_access`, below — precisely so an
|
||||||
unrecognised word records as evidence rather than as a decision.
|
unrecognised word records as evidence rather than as a decision.
|
||||||
|
|
||||||
`is_free_member` is SEPARATE from status and must stay that way. Patreon
|
`is_free_member` is SEPARATE from status and must stay that way. Patreon
|
||||||
@@ -396,3 +396,81 @@ class BaseNativeDownloader:
|
|||||||
sidecar_path = media_path.with_suffix(".json")
|
sidecar_path = media_path.with_suffix(".json")
|
||||||
sidecar_path.write_text(json.dumps(data, indent=2))
|
sidecar_path.write_text(json.dumps(data, indent=2))
|
||||||
return sidecar_path
|
return sidecar_path
|
||||||
|
|
||||||
|
# --- membership status vocabulary (#387) ------------------------------------
|
||||||
|
#
|
||||||
|
# Lives here, beside `Membership`, rather than in `membership_roster`. It is
|
||||||
|
# pure platform knowledge with no database behind it, and the platform clients
|
||||||
|
# need it too. Patreon's must tell a lapsed membership to a deleted creator
|
||||||
|
# (skippable) from a paid one it cannot attribute (drift), and a client may not
|
||||||
|
# import `membership_roster`: test_gated_reason.py forbids any fetch path from
|
||||||
|
# reaching the roster, so the roster can explain a skip but never cause one.
|
||||||
|
#
|
||||||
|
# Platform word -> whether the account currently has paid access.
|
||||||
|
#
|
||||||
|
# Every entry here must come from a CHARACTERISED response, never from API docs
|
||||||
|
# or a plausible guess — project rule 130, and inventing a status before seeing
|
||||||
|
# it in a real payload is exactly the failure it names.
|
||||||
|
#
|
||||||
|
# patreon: from a live capture of the operator's own session, 2026-09-10
|
||||||
|
# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and
|
||||||
|
# only those two are here.
|
||||||
|
#
|
||||||
|
# `declined_patron` is deliberately ABSENT even though it looks obviously
|
||||||
|
# right. It appears in the request's `filter[membership_type]`, and the capture
|
||||||
|
# proved that filter is NOT the same vocabulary as the attribute — a row
|
||||||
|
# selected by the filter as `free_member` came back with
|
||||||
|
# `patron_status: former_patron`, a word the filter does not contain. Reading
|
||||||
|
# the filter as an enum is the specific mistake the capture caught; adding
|
||||||
|
# `declined_patron` on the strength of it would be repeating that mistake one
|
||||||
|
# step later.
|
||||||
|
#
|
||||||
|
# Unknown words are NOT an error: an unrecognised status means the roster
|
||||||
|
# records evidence it cannot yet interpret, which is a better state than
|
||||||
|
# dropping the row or asserting a meaning for it.
|
||||||
|
#
|
||||||
|
# subscribestar: from a live capture of the account's /subscriptions page,
|
||||||
|
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
|
||||||
|
# a membership's state is which of two tables it sits in — so the "word" stored
|
||||||
|
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
|
||||||
|
# the whole vocabulary; there is nothing further to characterise later.
|
||||||
|
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
|
||||||
|
"patreon": {
|
||||||
|
"active_patron": True,
|
||||||
|
"former_patron": False,
|
||||||
|
},
|
||||||
|
"subscribestar": {
|
||||||
|
"active_subscriptions": True,
|
||||||
|
"cancelled_subscriptions": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_paid_access(
|
||||||
|
platform: str, status: str | None, *, is_free_member: bool = False,
|
||||||
|
) -> bool | None:
|
||||||
|
"""Does this membership mean the account currently PAYS for access?
|
||||||
|
|
||||||
|
Returns None for a status this code has not been taught, which callers must
|
||||||
|
treat as "unknown" rather than as False. The difference matters: False says
|
||||||
|
the operator has lost access, and asserting that from an unrecognised word
|
||||||
|
would tell them to cancel a source they are still paying for.
|
||||||
|
|
||||||
|
`is_free_member` is a second axis, not a status, and that is Patreon's
|
||||||
|
design rather than ours: the capture shows a free follow expressed as a
|
||||||
|
boolean alongside `patron_status`, so a "current" membership can still be
|
||||||
|
one nobody is paying for. Taking status alone would report a free follower
|
||||||
|
as a paying patron, and C4 would then never offer to clean it up.
|
||||||
|
|
||||||
|
(Honest limit: the capture contains no ACTIVE free member, so it cannot
|
||||||
|
demonstrate the two axes coming apart. The separation is what the payload's
|
||||||
|
shape says; the sample only shows it is possible, not that it happens.)
|
||||||
|
"""
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
|
||||||
|
if known is None:
|
||||||
|
return None
|
||||||
|
if not known:
|
||||||
|
return False
|
||||||
|
return not is_free_member
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from .native_ingest_common import (
|
|||||||
NativeDriftError,
|
NativeDriftError,
|
||||||
NativeIngestError,
|
NativeIngestError,
|
||||||
basename_from_url,
|
basename_from_url,
|
||||||
|
has_paid_access,
|
||||||
make_session,
|
make_session,
|
||||||
retry_after_seconds,
|
retry_after_seconds,
|
||||||
)
|
)
|
||||||
@@ -631,7 +632,25 @@ class PatreonClient:
|
|||||||
"cannot tell a complete roster from a truncated one"
|
"cannot tell a complete roster from a truncated one"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _membership(self, member: dict, index: dict) -> Membership:
|
def _membership(self, member: dict, index: dict) -> Membership | None:
|
||||||
|
"""One member row as a Membership, or None for a row the roster can skip.
|
||||||
|
|
||||||
|
The one skippable row is a LAPSED membership whose creator no longer
|
||||||
|
exists. The live roster (note #3886, CORRECTION 3) returned 104 rows,
|
||||||
|
because FC sends no membership-type filter and so gets lapses going back
|
||||||
|
years. One of them, a membership that ended in 2017, carried no
|
||||||
|
`campaign` relationship at all: the key is absent, not null, and its
|
||||||
|
reward names no campaign either. The creator's page is gone.
|
||||||
|
|
||||||
|
Raising on that row made the whole roster unusable over one membership
|
||||||
|
nobody can act on. Skipping it changes no conclusion. A lapsed
|
||||||
|
membership already means "not paying", absence means the same, and no
|
||||||
|
Source can be matched to a campaign that no longer has an id.
|
||||||
|
|
||||||
|
The refusal stays for every other row. An active or unrecognised
|
||||||
|
membership without a creator is something FC cannot vouch for, and
|
||||||
|
dropping it would read downstream as a cancellation.
|
||||||
|
"""
|
||||||
attrs = member.get("attributes") or {}
|
attrs = member.get("attributes") or {}
|
||||||
if "patron_status" not in attrs:
|
if "patron_status" not in attrs:
|
||||||
raise PatreonDriftError(
|
raise PatreonDriftError(
|
||||||
@@ -640,6 +659,17 @@ class PatreonClient:
|
|||||||
|
|
||||||
campaign_ids = self._related_ids(member, "campaign")
|
campaign_ids = self._related_ids(member, "campaign")
|
||||||
if not campaign_ids:
|
if not campaign_ids:
|
||||||
|
paid = has_paid_access(
|
||||||
|
"patreon", attrs.get("patron_status"),
|
||||||
|
is_free_member=bool(attrs.get("is_free_member")),
|
||||||
|
)
|
||||||
|
if paid is False:
|
||||||
|
log.info(
|
||||||
|
"Patreon roster: skipping a lapsed membership with no campaign "
|
||||||
|
"(creator deleted); status=%s access_expires_at=%s",
|
||||||
|
attrs.get("patron_status"), attrs.get("access_expires_at"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
raise PatreonDriftError(
|
raise PatreonDriftError(
|
||||||
"Patreon member resource has no campaign relationship — a "
|
"Patreon member resource has no campaign relationship — a "
|
||||||
"membership we cannot attribute to a creator is not usable"
|
"membership we cannot attribute to a creator is not usable"
|
||||||
@@ -700,7 +730,9 @@ class PatreonClient:
|
|||||||
index = self._transform(response)
|
index = self._transform(response)
|
||||||
rows = [m for m in (response.get("data") or []) if isinstance(m, dict)]
|
rows = [m for m in (response.get("data") or []) if isinstance(m, dict)]
|
||||||
for member in rows:
|
for member in rows:
|
||||||
yield self._membership(member, index)
|
membership = self._membership(member, index)
|
||||||
|
if membership is not None:
|
||||||
|
yield membership
|
||||||
|
|
||||||
seen += len(rows)
|
seen += len(rows)
|
||||||
total = int(response["meta"]["pagination"]["total"] or 0)
|
total = int(response["meta"]["pagination"]["total"] or 0)
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ _ROSTER_URL = f"{_ROSTER_BASE}/subscriptions"
|
|||||||
# `data-identifier`, the one vocabulary that names a state: the table class
|
# `data-identifier`, the one vocabulary that names a state: the table class
|
||||||
# inside the cancelled card says `for-unsubscribed_users`, a different word for
|
# inside the cancelled card says `for-unsubscribed_users`, a different word for
|
||||||
# the same list (note #3989, CORRECTION 1). The identifier is stored verbatim as
|
# the same list (note #3989, CORRECTION 1). The identifier is stored verbatim as
|
||||||
# Membership.status and mapped in membership_roster.MEMBERSHIP_STATUS.
|
# Membership.status and mapped in native_ingest_common.MEMBERSHIP_STATUS.
|
||||||
_ROSTER_ACTIVE = "active_subscriptions"
|
_ROSTER_ACTIVE = "active_subscriptions"
|
||||||
_ROSTER_CANCELLED = "cancelled_subscriptions"
|
_ROSTER_CANCELLED = "cancelled_subscriptions"
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
Colours are theme tokens (frontend/src/theme/fabled-tokens.js): obsidian
|
Colours are theme tokens (frontend/src/theme/fabled-tokens.js): obsidian
|
||||||
plate, accent gold. The plate is kept here (unlike logo.svg) so the tab
|
plate, accent gold. The plate is kept here (unlike logo.svg) so the tab
|
||||||
icon is self-contained against any browser chrome; on the nav it is
|
icon is self-contained against any browser chrome; on the nav it is
|
||||||
invisible because it matches --fc-chrome-rgb exactly. -->
|
invisible because it matches the fc-chrome-rgb custom property exactly.
|
||||||
|
No double hyphen may appear inside this comment: XML forbids it, and a
|
||||||
|
browser refuses to render an SVG that does not parse (it happened once —
|
||||||
|
tests/test_public_svgs.py). -->
|
||||||
<rect width="32" height="32" rx="6" fill="#14171A"/>
|
<rect width="32" height="32" rx="6" fill="#14171A"/>
|
||||||
<rect x="6.2" y="4.2" width="19.6" height="23.6" rx="1.4"
|
<rect x="6.2" y="4.2" width="19.6" height="23.6" rx="1.4"
|
||||||
fill="none" stroke="#A87338" stroke-width="2.4"/>
|
fill="none" stroke="#A87338" stroke-width="2.4"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -17,6 +17,22 @@ const route = useRoute()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-content {
|
.fc-content {
|
||||||
|
/* The full brand mark as one large, faint backdrop behind every page,
|
||||||
|
pinned to the viewport so content scrolls over it. Opaque surfaces
|
||||||
|
(cards, the nav) cover it; it shows in the gutters and on bare page
|
||||||
|
ground. The series reader is immersive and skips the shell, so reading
|
||||||
|
is never drawn over it.
|
||||||
|
|
||||||
|
Faded by laying the page colour over it at 94%, NOT with `opacity` on an
|
||||||
|
overlay element: an overlay needs this element to be z-indexed above it,
|
||||||
|
which makes all page content one stacking context under the nav and can
|
||||||
|
trap an in-page overlay beneath it. A background changes no stacking.
|
||||||
|
The mark is gold and parchment, close to the text colours, so it has to
|
||||||
|
stay this faint to keep text over it readable. */
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(var(--v-theme-background), 0.94), rgba(var(--v-theme-background), 0.94)),
|
||||||
|
url('/logo.svg') center / min(88vmin, 1100px) no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
/* NO padding-top: the TopNav is position:sticky, so it already reserves its
|
/* NO padding-top: the TopNav is position:sticky, so it already reserves its
|
||||||
own space in the v-app flex column — content flows directly below it. The
|
own space in the v-app flex column — content flows directly below it. The
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
// Single source of truth for platform → color + icon mapping. Used by
|
// Single source of truth for platform → color + icon mapping. Used by
|
||||||
// PlatformChip and any other GS-style platform-tagged surface. The five
|
// PlatformChip and any other GS-style platform-tagged surface. The four
|
||||||
// platforms FC supports map 1:1 to the GS palette; unknown platforms fall
|
// platforms FC supports map 1:1 to the GS palette; unknown platforms fall
|
||||||
// back to grey + mdi-web — which is deliberately what a retired platform
|
// back to grey + mdi-web — which is deliberately what a retired platform
|
||||||
// hits: a pre-#3069 deviantart source row still renders, as its raw key on
|
// hits: a pre-#3069 deviantart source row, or a post from pixiv (retired at
|
||||||
// a grey chip. Operator-confirmed scope 2026-05-27. The ICONS key set is
|
// milestone #406), still renders, as its raw key on a grey chip. So a
|
||||||
|
// retired platform is REMOVED from these maps, never kept "so old rows
|
||||||
|
// look right" — the fallback is what makes old rows look right, and keeping
|
||||||
|
// the entry would break the contract pin below. Operator-confirmed scope
|
||||||
|
// 2026-05-27. The ICONS key set is
|
||||||
// pinned against backend known_platform_keys() by
|
// pinned against backend known_platform_keys() by
|
||||||
// tests/test_fe_be_contract.py.
|
// tests/test_fe_be_contract.py.
|
||||||
|
|
||||||
@@ -12,7 +16,6 @@ const ICONS = {
|
|||||||
subscribestar: 'mdi-star',
|
subscribestar: 'mdi-star',
|
||||||
hentaifoundry: 'mdi-palette',
|
hentaifoundry: 'mdi-palette',
|
||||||
discord: 'mdi-discord',
|
discord: 'mdi-discord',
|
||||||
pixiv: 'mdi-alpha-p-box',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
@@ -20,7 +23,6 @@ const COLORS = {
|
|||||||
subscribestar: 'amber',
|
subscribestar: 'amber',
|
||||||
hentaifoundry: 'purple',
|
hentaifoundry: 'purple',
|
||||||
discord: 'indigo',
|
discord: 'indigo',
|
||||||
pixiv: 'blue',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABELS = {
|
const LABELS = {
|
||||||
@@ -28,7 +30,6 @@ const LABELS = {
|
|||||||
subscribestar: 'SubscribeStar',
|
subscribestar: 'SubscribeStar',
|
||||||
hentaifoundry: 'HentaiFoundry',
|
hentaifoundry: 'HentaiFoundry',
|
||||||
discord: 'Discord',
|
discord: 'Discord',
|
||||||
pixiv: 'Pixiv',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function platformIcon(platform) {
|
export function platformIcon(platform) {
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import pytest
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from backend.app.models import PlatformMembership
|
from backend.app.models import PlatformMembership
|
||||||
from backend.app.services.membership_roster import (
|
from backend.app.services.membership_roster import touch_membership
|
||||||
MEMBERSHIP_STATUS,
|
from backend.app.services.native_ingest_common import MEMBERSHIP_STATUS, has_paid_access
|
||||||
has_paid_access,
|
|
||||||
touch_membership,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@@ -239,14 +239,72 @@ def test_a_missing_data_list_is_drift(client, payload):
|
|||||||
list(client.iter_memberships(user_id="1"))
|
list(client.iter_memberships(user_id="1"))
|
||||||
|
|
||||||
|
|
||||||
def test_a_member_with_no_campaign_is_drift(client, payload):
|
def _index_of(payload, status):
|
||||||
|
return next(
|
||||||
|
i for i, m in enumerate(payload["data"])
|
||||||
|
if m["attributes"]["patron_status"] == status
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("shape", ["null", "absent"])
|
||||||
|
def test_an_active_member_with_no_campaign_is_drift(client, payload, shape):
|
||||||
|
"""A PAID membership FC cannot attribute must refuse the roster. Dropping it
|
||||||
|
would read downstream as the operator having cancelled it."""
|
||||||
mangled = json.loads(json.dumps(payload))
|
mangled = json.loads(json.dumps(payload))
|
||||||
mangled["data"][0]["relationships"]["campaign"] = {"data": None}
|
rels = mangled["data"][_index_of(mangled, "active_patron")]["relationships"]
|
||||||
|
if shape == "null":
|
||||||
|
rels["campaign"] = {"data": None}
|
||||||
|
else:
|
||||||
|
del rels["campaign"]
|
||||||
client._request = lambda *a, **k: mangled
|
client._request = lambda *a, **k: mangled
|
||||||
with pytest.raises(PatreonDriftError, match="campaign"):
|
with pytest.raises(PatreonDriftError, match="campaign"):
|
||||||
list(client.iter_memberships(user_id="1"))
|
list(client.iter_memberships(user_id="1"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unrecognised_status_with_no_campaign_is_drift(client, payload):
|
||||||
|
"""Only a status KNOWN to mean "not paying" may be skipped. An unknown word
|
||||||
|
could be a paid membership (has_paid_access returns None for it)."""
|
||||||
|
mangled = json.loads(json.dumps(payload))
|
||||||
|
row = mangled["data"][_index_of(mangled, "active_patron")]
|
||||||
|
row["attributes"]["patron_status"] = "some_new_status"
|
||||||
|
del row["relationships"]["campaign"]
|
||||||
|
client._request = lambda *a, **k: mangled
|
||||||
|
with pytest.raises(PatreonDriftError, match="campaign"):
|
||||||
|
list(client.iter_memberships(user_id="1"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lapsed_member_whose_creator_is_gone_is_skipped(client, payload):
|
||||||
|
"""The live roster's shape (note #3886, CORRECTION 3). A membership that
|
||||||
|
lapsed in 2017 came back with no `campaign` key at all, because the creator's
|
||||||
|
page no longer exists. It used to fail the whole roster. Now it is the only
|
||||||
|
row missing, and every other row still arrives."""
|
||||||
|
mangled = json.loads(json.dumps(payload))
|
||||||
|
lapsed = _index_of(mangled, "former_patron")
|
||||||
|
del mangled["data"][lapsed]["relationships"]["campaign"]
|
||||||
|
client._request = lambda *a, **k: mangled
|
||||||
|
rows = list(client.iter_memberships(user_id="1"))
|
||||||
|
assert len(rows) == len(payload["data"]) - 1
|
||||||
|
assert all(r.campaign_id for r in rows)
|
||||||
|
assert all(r.status == "active_patron" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skipping_does_not_end_paging_early(client, payload):
|
||||||
|
"""Paging counts the rows the SERVER sent, not the ones kept. Counting kept
|
||||||
|
rows would re-request an offset that was already read, or stop one page
|
||||||
|
short, whenever a row is skipped."""
|
||||||
|
first = json.loads(json.dumps(payload))
|
||||||
|
del first["data"][_index_of(first, "former_patron")]["relationships"]["campaign"]
|
||||||
|
total = 2 * len(payload["data"])
|
||||||
|
first["meta"]["pagination"]["total"] = total
|
||||||
|
second = json.loads(json.dumps(payload))
|
||||||
|
second["meta"]["pagination"]["total"] = total
|
||||||
|
calls = _serve(client, first, second)
|
||||||
|
rows = list(client.iter_memberships(user_id="1"))
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[1][1]["page[offset]"] == str(len(payload["data"]))
|
||||||
|
assert len(rows) == total - 1
|
||||||
|
|
||||||
|
|
||||||
def test_a_member_with_no_patron_status_is_drift(client, payload):
|
def test_a_member_with_no_patron_status_is_drift(client, payload):
|
||||||
mangled = json.loads(json.dumps(payload))
|
mangled = json.loads(json.dumps(payload))
|
||||||
del mangled["data"][0]["attributes"]["patron_status"]
|
del mangled["data"][0]["attributes"]["patron_status"]
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Every SVG the browser loads from `frontend/public/` must parse as XML.
|
||||||
|
|
||||||
|
A browser renders an SVG used as an image only if it is well-formed XML, and
|
||||||
|
when it is not, nothing reports it: no console error in most browsers, no
|
||||||
|
failed build, no failed request — the icon is simply blank. `favicon.svg`
|
||||||
|
shipped that way (merge #251) because its comment contained a CSS custom
|
||||||
|
property name, and `--` is illegal inside an XML comment. Both the tab icon and
|
||||||
|
the nav brand mark went missing, and only a person looking at the page noticed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PUBLIC = Path(__file__).resolve().parent.parent / "frontend" / "public"
|
||||||
|
SVGS = sorted(PUBLIC.rglob("*.svg"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_public_dir_has_svgs_to_check():
|
||||||
|
"""Guards the guard: a moved directory would otherwise pass vacuously."""
|
||||||
|
assert {p.name for p in SVGS} >= {"favicon.svg", "logo.svg"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("svg", SVGS, ids=lambda p: p.name)
|
||||||
|
def test_svg_is_well_formed_xml(svg):
|
||||||
|
root = ET.parse(svg).getroot()
|
||||||
|
assert root.tag == "{http://www.w3.org/2000/svg}svg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_parser_rejects_the_shape_that_broke_the_favicon():
|
||||||
|
"""Positive control: the exact defect must fail this parser, or the
|
||||||
|
parametrized test above proves nothing."""
|
||||||
|
broken = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||||
|
"<!-- matches --fc-chrome-rgb exactly --></svg>"
|
||||||
|
)
|
||||||
|
with pytest.raises(ET.ParseError):
|
||||||
|
ET.fromstring(broken)
|
||||||
@@ -388,14 +388,17 @@ async def test_update_while_enabled_keeps_failure_state(db):
|
|||||||
async def _source_with_content(db, svc, artist):
|
async def _source_with_content(db, svc, artist):
|
||||||
"""A source under `artist` with one post + one image it contributed."""
|
"""A source under `artist` with one post + one image it contributed."""
|
||||||
from backend.app.models import ImageProvenance, ImageRecord, Post
|
from backend.app.models import ImageProvenance, ImageRecord, Post
|
||||||
|
# Any registered platform will do — reassign never reads the platform, and
|
||||||
|
# never moves files (the storage path is immutable). This used pixiv until
|
||||||
|
# pixiv was retired (milestone #406) and `create` began refusing it.
|
||||||
rec = await svc.create(
|
rec = await svc.create(
|
||||||
artist_id=artist.id, platform="pixiv",
|
artist_id=artist.id, platform="hentaifoundry",
|
||||||
url=f"https://www.pixiv.net/users/{artist.id}",
|
url=f"https://www.hentai-foundry.com/user/{artist.slug}/profile",
|
||||||
)
|
)
|
||||||
post = Post(source_id=rec.id, artist_id=artist.id, external_post_id="p1")
|
post = Post(source_id=rec.id, artist_id=artist.id, external_post_id="p1")
|
||||||
db.add(post)
|
db.add(post)
|
||||||
img = ImageRecord(
|
img = ImageRecord(
|
||||||
path=f"/images/{artist.slug}/pixiv/pixiv/1_a_00.jpg",
|
path=f"/images/{artist.slug}/hentaifoundry/hentaifoundry/1_a_00.jpg",
|
||||||
sha256=str(artist.id).rjust(64, "0"), size_bytes=1, mime="image/jpeg",
|
sha256=str(artist.id).rjust(64, "0"), size_bytes=1, mime="image/jpeg",
|
||||||
width=1, height=1, origin="imported_filesystem",
|
width=1, height=1, origin="imported_filesystem",
|
||||||
integrity_status="unknown", artist_id=artist.id,
|
integrity_status="unknown", artist_id=artist.id,
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.app.services.membership_roster import has_paid_access, roster_user_id
|
from backend.app.services.membership_roster import roster_user_id
|
||||||
from backend.app.services.native_ingest_common import Membership
|
from backend.app.services.native_ingest_common import Membership, has_paid_access
|
||||||
from backend.app.services.subscribestar_client import (
|
from backend.app.services.subscribestar_client import (
|
||||||
SubscribeStarAuthError,
|
SubscribeStarAuthError,
|
||||||
SubscribeStarClient,
|
SubscribeStarClient,
|
||||||
|
|||||||
Reference in New Issue
Block a user