Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c3b74260 | ||
|
|
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,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card class="fc-post-card" variant="outlined">
|
<v-card
|
||||||
|
ref="cardEl" class="fc-post-card" variant="outlined"
|
||||||
|
:class="{ 'fc-post-card--wide': wide }"
|
||||||
|
>
|
||||||
<div class="fc-post-card__head">
|
<div class="fc-post-card__head">
|
||||||
<!-- Posts with no live subscription have source=null (alembic 0030);
|
<!-- Posts with no live subscription have source=null (alembic 0030);
|
||||||
show a "filesystem import" affordance instead of a platform chip. -->
|
show a "filesystem import" affordance instead of a platform chip. -->
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="rail.length || moreCount" class="fc-post-card__rail"
|
v-if="rail.length || moreCount" class="fc-post-card__rail"
|
||||||
:style="{ '--fc-rail-cols': railCols }"
|
:style="{ '--fc-rail-cols': railCols, '--fc-grid-cols': Math.min(2, railCols) }"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="t in rail" :key="t.image_id" type="button"
|
v-for="t in rail" :key="t.image_id" type="button"
|
||||||
@@ -221,20 +224,33 @@ const synthesisTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hero = computed(() => images.value[0])
|
const hero = computed(() => images.value[0])
|
||||||
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
|
|
||||||
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
|
// Filmstrip layout (milestone #407, option A). On a very wide window a card
|
||||||
// more images than fit, the last cell becomes a "+N" overflow tile so the
|
// that just grew would give one post the whole screen, so a WIDE card instead
|
||||||
// count stays accurate.
|
// pins the hero to a fixed height and moves the extra images into a 2-column
|
||||||
const RAIL_MAX = 5
|
// grid BESIDE it. Measured on the card rather than the viewport because the
|
||||||
|
// same card renders in the Latest feed, Browse, and the in-context view, each
|
||||||
|
// at a different width.
|
||||||
|
const WIDE_CARD_PX = 1100
|
||||||
|
const cardEl = ref(null)
|
||||||
|
const wide = ref(false)
|
||||||
|
|
||||||
|
// The narrow layout's strip spans the hero's full width (CSS grid, equal
|
||||||
|
// columns); the wide layout's grid is 2×2. Show up to that many cells; when
|
||||||
|
// there are more images than fit, the last cell becomes a "+N" overflow tile so
|
||||||
|
// the count stays accurate.
|
||||||
|
const RAIL_MAX_NARROW = 5
|
||||||
|
const RAIL_MAX_WIDE = 4
|
||||||
const serverMore = computed(() => props.post.thumbnails_more || 0)
|
const serverMore = computed(() => props.post.thumbnails_more || 0)
|
||||||
const afterHero = computed(() => images.value.slice(1))
|
const afterHero = computed(() => images.value.slice(1))
|
||||||
|
const railMax = computed(() => (wide.value ? RAIL_MAX_WIDE : RAIL_MAX_NARROW))
|
||||||
const hasOverflow = computed(
|
const hasOverflow = computed(
|
||||||
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
|
() => serverMore.value > 0 || afterHero.value.length > railMax.value,
|
||||||
)
|
)
|
||||||
const rail = computed(() =>
|
const rail = computed(() =>
|
||||||
hasOverflow.value
|
hasOverflow.value
|
||||||
? afterHero.value.slice(0, RAIL_MAX - 1)
|
? afterHero.value.slice(0, railMax.value - 1)
|
||||||
: afterHero.value.slice(0, RAIL_MAX),
|
: afterHero.value.slice(0, railMax.value),
|
||||||
)
|
)
|
||||||
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||||
const moreCount = computed(() => {
|
const moreCount = computed(() => {
|
||||||
@@ -349,8 +365,17 @@ function measureOverflow () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ro = null
|
let ro = null
|
||||||
|
let cardRo = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(measureOverflow)
|
nextTick(measureOverflow)
|
||||||
|
const root = cardEl.value?.$el
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && root) {
|
||||||
|
cardRo = new ResizeObserver((entries) => {
|
||||||
|
const w = entries[0]?.contentRect?.width ?? 0
|
||||||
|
wide.value = w >= WIDE_CARD_PX
|
||||||
|
})
|
||||||
|
cardRo.observe(root)
|
||||||
|
}
|
||||||
// Re-measure when the card resizes (the container-query clamp differs by
|
// Re-measure when the card resizes (the container-query clamp differs by
|
||||||
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
|
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
|
||||||
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
|
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
|
||||||
@@ -358,7 +383,10 @@ onMounted(() => {
|
|||||||
ro.observe(descEl.value)
|
ro.observe(descEl.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
|
onBeforeUnmount(() => {
|
||||||
|
if (ro) { ro.disconnect(); ro = null }
|
||||||
|
if (cardRo) { cardRo.disconnect(); cardRo = null }
|
||||||
|
})
|
||||||
|
|
||||||
async function toggleDesc () {
|
async function toggleDesc () {
|
||||||
if (!descExpanded.value) {
|
if (!descExpanded.value) {
|
||||||
@@ -495,6 +523,40 @@ function formatBytes (n) {
|
|||||||
color: rgb(var(--v-theme-accent));
|
color: rgb(var(--v-theme-accent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Filmstrip layout (wide cards, #407 A) ---------------------------------
|
||||||
|
The hero has a HEIGHT, not a width: a wide card must not turn into a
|
||||||
|
full-screen post, so its height stays roughly a third of the viewport
|
||||||
|
whatever the window's width. The extra images sit beside it as square cells
|
||||||
|
whose size derives from that same height, so the grid always ends flush with
|
||||||
|
the hero's bottom edge. */
|
||||||
|
.fc-post-card--wide {
|
||||||
|
--fc-hero-h: clamp(260px, 34vh, 460px);
|
||||||
|
--fc-grid-gap: 8px;
|
||||||
|
--fc-cell: calc((var(--fc-hero-h) - var(--fc-grid-gap)) / 2);
|
||||||
|
}
|
||||||
|
.fc-post-card--wide .fc-post-card__body { flex-direction: row; gap: 24px; }
|
||||||
|
.fc-post-card--wide .fc-post-card__media {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--fc-grid-gap);
|
||||||
|
}
|
||||||
|
.fc-post-card--wide .fc-post-card__hero {
|
||||||
|
width: auto;
|
||||||
|
height: var(--fc-hero-h);
|
||||||
|
}
|
||||||
|
.fc-post-card--wide .fc-post-card__rail {
|
||||||
|
margin-top: 0;
|
||||||
|
gap: var(--fc-grid-gap);
|
||||||
|
grid-template-columns: repeat(var(--fc-grid-cols, 2), var(--fc-cell));
|
||||||
|
grid-auto-rows: var(--fc-cell);
|
||||||
|
}
|
||||||
|
.fc-post-card--wide .fc-post-card__rail-cell,
|
||||||
|
.fc-post-card--wide .fc-post-card__rail-more { height: 100%; }
|
||||||
|
.fc-post-card--wide .fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
||||||
|
/* Text is secondary here — long reads happen in the expanded view — so the
|
||||||
|
clamp keeps the text column no taller than the images beside it. */
|
||||||
|
.fc-post-card--wide .fc-post-card__desc--clamped { -webkit-line-clamp: 4; }
|
||||||
|
|
||||||
.fc-post-card__title {
|
.fc-post-card__title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 18px; font-weight: 700;
|
font-size: 18px; font-weight: 700;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="pt-2 pb-6" max-width="900">
|
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is
|
||||||
|
today's 900px column, and above it the feed widens and gains the rail
|
||||||
|
and day gutter (milestone #407). -->
|
||||||
|
<v-container fluid class="pt-2 pb-6 fc-posts">
|
||||||
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
||||||
scroll — newer posts load above, older posts below. -->
|
scroll — newer posts load above, older posts below. -->
|
||||||
<template v-if="postIdFilter != null">
|
<template v-if="postIdFilter != null">
|
||||||
@@ -48,15 +51,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Normal feed -->
|
<!-- Normal feed -->
|
||||||
<template v-else>
|
<div v-else class="fc-posts__layout">
|
||||||
<FeedStatusRibbon v-if="statusRibbon" />
|
<!-- On a wide window this is a sticky left rail (#407 E); below the
|
||||||
|
breakpoint it lays out exactly as the old inline header did. -->
|
||||||
|
<aside class="fc-posts__rail">
|
||||||
<PostsFilterBar
|
<PostsFilterBar
|
||||||
:artist-id="artistFilter"
|
:artist-id="artistFilter"
|
||||||
:platform="platformFilter"
|
:platform="platformFilter"
|
||||||
@update:filters="onFilters"
|
@update:filters="onFilters"
|
||||||
/>
|
/>
|
||||||
|
<FeedStatusRibbon v-if="statusRibbon" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="fc-posts__main">
|
||||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||||
{{ String(store.error) }}
|
{{ String(store.error) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -74,14 +81,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
|
<!-- Day groups (#407 D). The heading sits above its posts on a narrow
|
||||||
|
window and in a sticky left gutter on a wide one. -->
|
||||||
|
<section v-for="d in days" :key="d.key" class="fc-posts__day">
|
||||||
|
<header class="fc-posts__day-head">
|
||||||
|
<span class="fc-posts__day-label">{{ d.label }}</span>
|
||||||
|
<span class="fc-posts__day-count">
|
||||||
|
{{ d.posts.length }} post{{ d.posts.length === 1 ? '' : 's' }}
|
||||||
|
· {{ d.artistCount }} artist{{ d.artistCount === 1 ? '' : 's' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="fc-posts__day-posts">
|
||||||
|
<PostCard v-for="p in d.posts" :key="p.id" :post="p" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div ref="sentinel" class="fc-posts__sentinel">
|
<div ref="sentinel" class="fc-posts__sentinel">
|
||||||
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
||||||
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -121,6 +142,48 @@ const hasActiveFilter = computed(() =>
|
|||||||
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
|
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- day groups (#407 D) ---
|
||||||
|
// CONSECUTIVE runs, not a bucket per date. The feed's sort key includes
|
||||||
|
// `resurfaced_at` (a Discord grouping that grew moves back to the top), which
|
||||||
|
// the payload does not carry, so a resurfaced post can sit above newer ones.
|
||||||
|
// Bucketing by date would pull it out of order; a run gives it its own heading
|
||||||
|
// where it actually appears. Counts cover what has LOADED, and grow as the
|
||||||
|
// infinite scroll fetches more of the same day.
|
||||||
|
function dayKey (iso) {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||||
|
}
|
||||||
|
function dayLabel (iso) {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const today = new Date()
|
||||||
|
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
||||||
|
const days = Math.round((startOf(today) - startOf(d)) / 86400000)
|
||||||
|
if (days === 0) return 'Today'
|
||||||
|
if (days === 1) return 'Yesterday'
|
||||||
|
if (days > 1 && days < 7) return d.toLocaleDateString(undefined, { weekday: 'long' })
|
||||||
|
const sameYear = d.getFullYear() === today.getFullYear()
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const days = computed(() => {
|
||||||
|
const groups = []
|
||||||
|
for (const p of store.items) {
|
||||||
|
const iso = p.post_date || p.downloaded_at
|
||||||
|
const key = dayKey(iso)
|
||||||
|
let g = groups[groups.length - 1]
|
||||||
|
if (!g || g.dayKey !== key) {
|
||||||
|
// Suffix with the run index so a day that appears twice (see above)
|
||||||
|
// still has a unique v-for key.
|
||||||
|
g = { key: `${key}#${groups.length}`, dayKey: key, label: dayLabel(iso), posts: [], artists: new Set() }
|
||||||
|
groups.push(g)
|
||||||
|
}
|
||||||
|
g.posts.push(p)
|
||||||
|
if (p.artist?.id != null) g.artists.add(p.artist.id)
|
||||||
|
}
|
||||||
|
return groups.map((g) => ({ ...g, artistCount: g.artists.size }))
|
||||||
|
})
|
||||||
|
|
||||||
// Drop only `post_id` and stay where we are — keeps Browse's `tab=posts` (and
|
// Drop only `post_id` and stay where we are — keeps Browse's `tab=posts` (and
|
||||||
// any active artist/platform scope) intact instead of resetting the surface.
|
// any active artist/platform scope) intact instead of resetting the surface.
|
||||||
const allPostsTarget = computed(() => {
|
const allPostsTarget = computed(() => {
|
||||||
@@ -232,6 +295,80 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Below the breakpoint: today's layout exactly — a 900px column with the
|
||||||
|
filters and ribbon inline above the feed. */
|
||||||
|
.fc-posts { max-width: 900px; }
|
||||||
|
.fc-posts__day-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
.fc-posts__day-label {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-posts__day-count { font-size: 0.78rem; }
|
||||||
|
|
||||||
|
/* Wide window (#407 D + E). The rail holds filters and status; each day's
|
||||||
|
heading moves into a sticky gutter beside its posts; the column widens and
|
||||||
|
the cards switch to their filmstrip layout on their own (PostCard measures
|
||||||
|
itself). 1600px is where a 280px rail and a 150px gutter still leave a card
|
||||||
|
wide enough to be worth the change. */
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
.fc-posts { max-width: 2360px; }
|
||||||
|
.fc-posts__layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 40px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.fc-posts__rail {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--fc-nav-h, 64px) + 16px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.fc-posts__rail :deep(.fc-posts-filters) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.fc-posts__rail :deep(.fc-posts-filters__artist),
|
||||||
|
.fc-posts__rail :deep(.fc-posts-filters__platform) {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.fc-posts__rail :deep(.fc-ribbon) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.fc-posts__main { max-width: 1900px; }
|
||||||
|
.fc-posts__day {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr);
|
||||||
|
gap: 0 24px;
|
||||||
|
}
|
||||||
|
.fc-posts__day-head {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--fc-nav-h, 64px) + 16px);
|
||||||
|
align-self: start;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
padding-top: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.fc-posts__day-label { font-size: 1.1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.fc-posts__loading,
|
.fc-posts__loading,
|
||||||
.fc-posts__empty {
|
.fc-posts__empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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