Merge ticket/146-forced-poll

This commit is contained in:
2026-08-22 01:05:09 +07:00
14 changed files with 855 additions and 49 deletions
+18 -3
View File
@@ -347,11 +347,13 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
return defaultGap
}
fig.Due = len(due)
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) && !anyForced(due) {
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
// browser): waking it for a single Poll would cost a challenge solve
// per request. The Lane still paces at the default gap, which is what
// the owner's page must show rather than a zero.
// per request. A forced Series is the one exception — a human asking
// is not the machine waking itself (issue #146). The Lane still paces
// at the default gap, which is what the owner's page must show rather
// than a zero.
rec.skip = SkipAsleep
fig.Gap = defaultGap
return defaultGap
@@ -539,6 +541,19 @@ func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool
return maxSeriesWait(due, now, rest) >= browserWakeAge
}
// anyForced reports whether the due list holds a forced Series: one whose
// owner check-now request (issue #146) has not been answered yet. A human
// asking wakes a sleeping Chrome even below the wake thresholds; the request
// itself still ages visibly if the home machine is off.
func anyForced(due []store.Series) bool {
for _, sr := range due {
if sr.Forced {
return true
}
}
return false
}
// maxSeriesWait returns how long the most-overdue of the due Series has been
// waiting past its due moment (0 when due is empty).
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
+138
View File
@@ -2098,3 +2098,141 @@ func TestPassRetentionCutoffIsFourteenDays(t *testing.T) {
t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n)
}
}
// stampRecordingFetcher is a fakeFetcher that records the Series' check stamp
// at call time — the seam that proves the check stamp is written before the
// fetch (issue #146). If the order were swapped, the recorded stamp would be
// the pre-pass value and the ordering assertion would fail.
type stampRecordingFetcher struct {
fakeFetcher
store *store.Store
site string
seriesID string
stampAtCall int64
}
func (f *stampRecordingFetcher) Get(ctx context.Context, url string) (string, int, error) {
ts, err := f.store.LatestCheckedAt(f.site, f.seriesID)
if err != nil {
return "", 0, err
}
f.stampAtCall = ts
return f.fakeFetcher.Get(ctx, url)
}
// The check stamp is written before the fetch is attempted: the fake fetcher
// records the stamp it sees at call time, and it must already be the pass's
// own stamp. The order is load-bearing — a forced request is pending while
// force_poll_at > latest_checked_at, so stamping after the fetch would make a
// failed forced request sticky — and the assertion fails if it is swapped.
func TestCheckStampIsWrittenBeforeFetch(t *testing.T) {
s, _ := newTestStore(t)
const (
key = "asura:chronicles-of-the-demon-faction-f886a8af"
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
)
seedForCheck(t, s, key, seriesURL, 0)
now := time.UnixMilli(7_000_000)
rec := &stampRecordingFetcher{
fakeFetcher: fakeFetcher{body: asuraSeriesFixture, status: 200},
store: s, site: "asura", seriesID: "chronicles-of-the-demon-faction-f886a8af",
}
newTestPoller(t, s, rec, now).runOnce(context.Background())
if rec.stampAtCall != now.UnixMilli() {
t.Fatalf("check stamp at fetch time = %d, want %d (the stamp must be written before the fetch)", rec.stampAtCall, now.UnixMilli())
}
}
// A forced Series whose attempt fails is no longer pending: the check stamp
// is written before the fetch, so the first attempt ends the pending state
// whatever it returns. A naive implementation (stamp only on success, or
// after the fetch) leaves the request sticky and the row due next pass.
func TestForcedSeriesSelfClearsOnFailedAttempt(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(7_000_000)
const url = "https://asurascans.com/comics/x"
// Freshly checked, so only the force flag makes it due.
seedForCheck(t, s, "asura:x", url, now.Add(-30*time.Minute).UnixMilli())
if err := s.ForceSeriesPoll("asura", "x", now.UnixMilli()); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err := s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:x" || !due[0].Forced {
t.Fatalf("forced row not due and flagged before the attempt: %+v", due)
}
// The attempt fails, but the attempt still happened: the row is stamped
// and no longer pending.
f := &fakeFetcher{err: errors.New("dial tcp: refused")}
newTestPoller(t, s, f, now).runOnce(context.Background())
if got := readLatestCheckedAt(t, s, "asura:x"); got != now.UnixMilli() {
t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli())
}
due, err = s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("failed attempt left the forced row due: %v", due)
}
}
// The refusal backoff is a Lane gate, not a Series gate: a forced Series does
// not override a Site that is actively refusing, because hand-forcing a
// request into a refusal only makes it worse (issue #146).
func TestForcedSeriesDoesNotOverrideRefusalBackoff(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
seedForCheck(t, s, "kagane:w", "https://kagane.to/series/w", 0)
if err := s.ForceSeriesPoll("kagane", "w", now.UnixMilli()); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
if err := s.SetLaneRefusal("kagane", now.Add(RefuseBackoff).UnixMilli()); err != nil {
t.Fatalf("SetLaneRefusal: %v", err)
}
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
p := &Poller{
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
if got := browser.callCount(); got != 0 {
t.Fatalf("browser fetches through a refusal backoff = %d, want 0", got)
}
}
// A forced Series wakes a sleeping browser Lane: the wake thresholds exist to
// stop the machine waking itself for one unattended check, and a human asking
// is not that (issue #146). Below both thresholds the Lane still sleeps when
// nothing is forced.
func TestForcedSeriesWakesSleepingBrowser(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
// Freshly due: checked two minutes before the rest elapses, so the wait
// is far below browserWakeAge and the count is under browserWakeCount.
seedForCheck(t, s, "kagane:w1", "https://kagane.to/series/w1", now.Add(-62*time.Minute).UnixMilli())
seedForCheck(t, s, "kagane:w2", "https://kagane.to/series/w2", now.Add(-62*time.Minute).UnixMilli())
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
p := &Poller{
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
if got := browser.callCount(); got != 0 {
t.Fatalf("browser fetches without a forced series = %d, want 0 (Chrome stays asleep)", got)
}
if err := s.ForceSeriesPoll("kagane", "w1", now.UnixMilli()); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
p.runOnce(context.Background())
if got := browser.callCount(); got != 2 {
t.Fatalf("browser fetches with a forced series = %d, want 2 (the lane wakes)", got)
}
}
+9 -4
View File
@@ -41,7 +41,7 @@ type SeriesFilter struct {
// must never leave the store package — so the projection does not select it,
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at`
// raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
@@ -68,8 +68,12 @@ type AdminSeries struct {
LatestChapter string
LatestChapterNum *float64 // nil until first captured
LatestCheckedAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
// meaning never asked. Pending is derived, never stored: a request is
// pending while ForcePollAt is newer than LatestCheckedAt.
ForcePollAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
}
// SeriesPage is one page of the owner's filtered Series list plus the count
@@ -185,7 +189,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
`+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.latest_raised_by
s.force_poll_at, s.latest_raised_by
`+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
@@ -260,6 +264,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt,
&a.RaisedByReader, &a.ReaderCount, &total,
); err != nil {
return AdminSeries{}, 0, err
+46
View File
@@ -348,3 +348,49 @@ func TestAdminFilterUnknownNameRejected(t *testing.T) {
}
}
}
// ForceSeriesPoll is the idempotent stamp write: a second press overwrites
// the request time, and touching a missing series is not an error.
func TestForceSeriesPollStampsIdempotently(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
if err := s.ForceSeriesPoll("asura", "x", 42); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
if err := s.ForceSeriesPoll("asura", "x", 99); err != nil {
t.Fatalf("ForceSeriesPoll re-stamp: %v", err)
}
// Touching a missing series is not an error: the row may have been
// orphaned, and the caller's read decides what exists.
if err := s.ForceSeriesPoll("asura", "ghost", 99); err != nil {
t.Fatalf("ForceSeriesPoll missing: %v", err)
}
var got int64
if err := s.db.QueryRow(
`SELECT force_poll_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
t.Fatalf("read force_poll_at: %v", err)
}
if got != 99 {
t.Fatalf("force_poll_at = %d, want 99 (the later press wins)", got)
}
}
// The admin projection carries the force stamp so the web layer can derive
// the pending flag without a second read.
func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
if err := s.ForceSeriesPoll("asura", "x", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
page, err := s.SeriesPage(SeriesFilter{})
if err != nil {
t.Fatalf("SeriesPage: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].ForcePollAt != 5000 {
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
}
}
+48 -9
View File
@@ -88,6 +88,11 @@ type Series struct {
// readerCount is the number of bookmarks referencing this series, filled
// only by the due-queue query that orders on it.
readerCount int
// Forced is whether the owner asked for a check now (issue #146): the
// request stamp is newer than the check stamp. Derived in the due query,
// never stored, and the flag that jumps the queue and opens the browser
// wake gate.
Forced bool
}
// LanePass is one Poll Lane's durable pass snapshot. Pause and refusal stamps
@@ -621,8 +626,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
}
// scanSeries reads one row in seriesColumns order, plus the due query's
// reader_count column. latest_chapter_num and latest_raised_by are both
// nullable, same as latest_chapter_num on the bookmark read path.
// forced flag and reader_count columns. latest_chapter_num and
// latest_raised_by are both nullable, same as latest_chapter_num on the
// bookmark read path.
func scanSeries(scan func(...any) error) (Series, error) {
var (
sr Series
@@ -632,7 +638,7 @@ func scanSeries(scan func(...any) error) (Series, error) {
if err := scan(
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
&sr.readerCount,
&sr.Forced, &sr.readerCount,
); err != nil {
return Series{}, err
}
@@ -1164,6 +1170,13 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
// limit — the Lane's own gap paces the fetches, and the batch size that used
// to cap this query is gone with the shared pace.
//
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
// clause and the finished-only bucket. It never overrides an empty
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
// the result — so those stay unconditional. Forced rows sort to the front of
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
//
// The reader_count ordering is the point of the split (ADR-0003): a series
// shared by several readers is fetched once per due cycle, and the popular
// ones stay freshest while the long tail absorbs any shortfall. Within one
@@ -1192,19 +1205,26 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
// allowed to defer at all was settled when the Sighting was recorded — see
// RecordSighting.
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
(s.force_poll_at > s.latest_checked_at) AS forced,
COUNT(*) AS reader_count
FROM series s
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
WHERE s.site = $1
AND s.series_url <> ''
AND s.latest_checked_at <= $2::bigint
AND (s.latest_checked_at <= $2::bigint
OR s.force_poll_at > s.latest_checked_at)
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
OR s.force_poll_at > s.latest_checked_at)
AND (COUNT(*) > 1
OR s.latest_sighted_at <= $2::bigint
OR s.latest_checked_at <= $3::bigint)
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
OR s.latest_checked_at <= $3::bigint
OR s.force_poll_at > s.latest_checked_at)
ORDER BY (s.force_poll_at > s.latest_checked_at) DESC,
reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
if err != nil {
return nil, fmt.Errorf("query due series: %w", err)
}
@@ -1264,6 +1284,25 @@ func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error {
return nil
}
// ForceSeriesPoll stamps a Series with the owner's "check now" request
// (issue #146): a fact about the Series the Lane's next pass reads through
// DueForLatestCheck, never a command to the poller — so the request survives
// a restart. Writing again overwrites the request time; the write is
// idempotent. Touching a missing series is not an error: the row may have
// been orphaned, and the caller's read decides what exists. The stamp never
// expires by itself — an unanswered request keeps ageing — and pending is
// derived as force_poll_at > latest_checked_at, which is why the poller's
// check stamp is written before the fetch: the first attempt ends the
// pending state whatever it returns.
func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error {
if _, err := s.db.Exec(
`UPDATE series SET force_poll_at = $1 WHERE site = $2 AND series_id = $3`,
at, site, seriesID); err != nil {
return fmt.Errorf("force poll %s:%s: %w", site, seriesID, err)
}
return nil
}
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
// tests outside this package (the poller's own tests assert on rest
// bookkeeping) — see MarkLatestChecked for why the field stays off the
+152 -1
View File
@@ -758,7 +758,7 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
t.Helper()
sr, err := scanSeries(s.db.QueryRow(
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
`SELECT `+seriesColumns+`, false AS forced, 0 AS reader_count FROM series s
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
if err != nil {
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
@@ -1748,3 +1748,154 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
t.Fatalf("lane state rows after resume = %d, want 1", rows)
}
}
// A forced Series is due ahead of the rest cutoff: the request overrides the
// rest gate so the Lane's next pass picks it up however recently it was
// checked. An unforced series under the rest stays out.
func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) {
s := newTestStore(t)
const now = int64(10 * 3600_000)
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000)
seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000)
if err := s.ForceSeriesPoll("asura", "forced", now); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:forced" {
t.Fatalf("due = %v, want only the forced series", due)
}
}
// The Sighting-deferral clause holds a solitary series back after a recent
// Reader report; a forced request overrides it and puts the series back on
// the Lane's list.
func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
s := newTestStore(t)
const now = int64(10 * 3600_000)
// One bookmark (so deferral can apply), sighted and checked 10m ago:
// inside the deferral window and under the ceiling.
seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000)
if _, err := s.db.Exec(
`UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`,
now-10*60_000); err != nil {
t.Fatalf("seed sighting: %v", err)
}
// Unforced: deferred, and under the rest anyway.
due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("unforced deferred series is due: %v", due)
}
// Forced: the request overrides the deferral.
if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:deferred" {
t.Fatalf("forced deferred series not due: %v", due)
}
}
// The finished-only bucket excludes a series whose only bookmarks are
// finished; a forced request overrides it — the owner asked, so the Lane
// looks.
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
if _, err := s.Upsert(s.OwnerID(), Bookmark{
Key: "asura:finished", Site: "asura", SeriesID: "finished",
SeriesURL: "https://asurascans.com/comics/finished",
Status: StatusFinished, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed finished: %v", err)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
for _, sr := range due {
if sr.Key() == "asura:finished" {
t.Fatalf("unforced finished series is due: %v", due)
}
}
if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
got := map[string]bool{}
for _, sr := range due {
got[sr.Key()] = true
}
if !got["asura:reading"] || !got["asura:finished"] {
t.Fatalf("forced finished series not due: %v", due)
}
}
// A forced Series jumps the queue: it sorts ahead of a more-read series that
// is due by rest, without disturbing the reader-count-then-age tie-break
// among the unforced rows (ADR-0003).
func TestDueForLatestCheckForcedSortsFirst(t *testing.T) {
s := newTestStore(t)
// "popular" has two readers and is long overdue; "forced" has one reader
// and a fresh check stamp. The forced row must come first.
seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100)
seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001)
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900)
if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 2 {
t.Fatalf("due = %d rows, want 2", len(due))
}
if due[0].Key() != "asura:forced" || due[1].Key() != "asura:popular" {
t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key())
}
}
// A forced Series with no series URL is still not fetched — nothing to fetch —
// and one with no Bookmarks is still excluded by the join. The force flag
// opens the three gates it is allowed to, not the whole query.
func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:nourl", "", 0)
if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
if _, err := s.db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover, kind,
latest_chapter, latest_chapter_num, latest_checked_at,
force_poll_at)
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
'', 'manga', '', NULL, 0, 5000)`); err != nil {
t.Fatalf("seed orphan: %v", err)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
}
}
+1
View File
@@ -46,6 +46,7 @@ func (h *Handler) adminRoutes() []adminRoute {
{"GET /admin/readers", h.adminReaders},
{"GET /admin/series", h.adminSeries},
{"GET /admin/series/{key}", h.adminSeriesDetail},
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
{"GET /ui/admin/lanes", h.uiLanes},
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
+98 -5
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"bookmarkmanager/backend/internal/latest"
@@ -80,6 +81,11 @@ type seriesFilterOption struct {
// the alternating row tint by class rather than nth-of-type, so the confirm
// rows later tickets add are row siblings without breaking the alternation.
// Attention tints the title patina: a row with any hygiene chip needs one.
//
// CanPoll is the Check now control's visibility: absent on a Series with no
// page to fetch and on an orphan, so the owner is never offered a button that
// can never do anything. Pending is derived — the request stamp is newer than
// the check stamp — and Requested is its ageing label.
type seriesRowView struct {
Key string
Title string
@@ -91,6 +97,9 @@ type seriesRowView struct {
More int // chips past the cap, rendered as a +N tail
Band bool
Attention bool
CanPoll bool
Pending bool
Requested string // "requested 3m ago", rendered only while pending
}
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
@@ -106,6 +115,63 @@ func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
}
// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at
// and answers with the freshly rendered row, so the figures describe the
// state after the press. The control never commands the poller — the request
// is a fact about the Series, and the Lane's next pass reads it through
// DueForLatestCheck (ADR-0013). The owner gate is the route's, not this
// handler's; the body is capped like the API path caps its bodies; the key is
// validated here — a malformed key is a 400 and an unknown one a 404.
func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.Error(w, "bad series key", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else if !found {
http.NotFound(w, r)
return
}
if err := h.store.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Re-read after the stamp: the answer must describe the state after the
// press. The detail page's control swaps its meta in place and the list
// row's swaps the row; htmx names an id target in HX-Target, so the
// response matches the surface it came from. The row's band parity travels
// with the press (hx-vals), so the swap keeps the zebra alternation.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
if r.Header.Get("HX-Target") == "detail-meta" {
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
return
}
band := 0
if r.PostFormValue("band") == "1" {
band = 1
}
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
}
// seriesListView assembles one Series list view from the request's query
// string. An unknown filter value is the absent All case, never an error: the
// select's options are not the only way this URL can be reached.
@@ -206,13 +272,32 @@ func (h *Handler) seriesFilterOptions(selected string, cutoff int64) ([]seriesFi
// seriesRow shapes one store row for the template, capping its chips at two
// plus a +N tail; attention marks a row that carries any.
// pollState derives the Check now control and the pending marker (issue
// #146), shared by the list row and the detail page: CanPoll is false on a
// Series with no page to fetch and on an orphan, so the owner is never
// offered a button that can never do anything. Pending is derived — the
// request stamp is newer than the check stamp — and requested is its ageing
// label, which never expires.
func pollState(a store.AdminSeries, now time.Time) (canPoll, pending bool, requested string) {
canPoll = a.SeriesURL != "" && a.ReaderCount > 0
if a.ForcePollAt > a.LatestCheckedAt {
pending = true
requested = requestedAge(now, a.ForcePollAt)
}
return canPoll, pending, requested
}
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
canPoll, pending, requested := pollState(a, now)
row := seriesRowView{
Key: a.Key(),
Title: a.Title,
Site: a.Site,
Readers: a.ReaderCount,
Band: i%2 == 1,
Key: a.Key(),
Site: a.Site,
Title: a.Title,
Readers: a.ReaderCount,
Band: i%2 == 1,
CanPoll: canPoll,
Pending: pending,
Requested: requested,
}
if a.LatestChapterNum != nil {
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
@@ -273,6 +358,14 @@ func checkedAge(now time.Time, ts int64) string {
}
}
// requestedAge is the pending marker's text: how long ago the owner asked,
// and nothing about when the request will run — the page does not know when a
// sleeping browser will wake (issue #146). An unanswered request ages forever;
// there is no expiry.
func requestedAge(now time.Time, ts int64) string {
return "requested " + checkedAge(now, ts)
}
// pagerRange is the pager's "1–50 of 120" line. The template renders the
// pager only over rows (the empty state replaces it), so it is never asked
// to describe an empty list.
+37 -9
View File
@@ -31,6 +31,15 @@ type seriesDetailView struct {
NoCover bool
Orphan bool // no Reader holds the Series
SightingRaised bool // a Reader's Sighting set the Latest Chapter
// Poll is the Check now control and the pending marker (issue #146): the
// same derivation and visibility as the list row. CanPoll is false on a
// Series with no page to fetch and on an orphan; Pending is derived —
// the request stamp is newer than the check stamp — and Requested is its
// ageing label.
CanPoll bool
Pending bool
Requested string
}
// adminSeriesDetail renders one Series' page, keyed by the composite
@@ -38,40 +47,56 @@ type seriesDetailView struct {
// the list's own SeriesPage read narrowed to the key's Site: the admin
// projection is the privacy boundary, and a dedicated single-row read would
// be a second definition of it.
// ponytail: a page scan per detail view, one query per page of the Site's
// rows up to the window total; a keyed read alongside SeriesPage when the
// library outgrows the page size.
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.NotFound(w, r)
return
}
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series detail %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
}
// adminSeriesByKey reads one Series through the list's own SeriesPage read
// narrowed to the key's Site: the admin projection is the privacy boundary,
// and a dedicated single-row read would be a second definition of it. Absence
// is reported with found=false, never an error.
// ponytail: a page scan per keyed read, one query per page of the Site's rows
// up to the window total; a keyed read alongside SeriesPage when the library
// outgrows the page size.
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
seen := 0
for page := 1; ; page++ {
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
if err != nil {
log.Printf("series detail %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
return store.AdminSeries{}, false, err
}
seen += len(p.Rows)
for i := range p.Rows {
if p.Rows[i].SeriesID == seriesID {
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(p.Rows[i])})
return
return p.Rows[i], true, nil
}
}
if seen >= p.Total {
break
}
}
http.NotFound(w, r)
return store.AdminSeries{}, false, nil
}
// seriesDetailView shapes one AdminSeries row for display: every judgement in
// Go, the template left to print strings and flags.
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
canPoll, pending, requested := pollState(a, time.Now())
v := seriesDetailView{
Key: a.Key(),
Site: a.Site,
@@ -83,6 +108,9 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
NoCover: a.CoverAddress == "",
Orphan: a.ReaderCount == 0,
SightingRaised: a.RaisedByReader,
CanPoll: canPoll,
Pending: pending,
Requested: requested,
}
if a.LatestChapterNum == nil {
v.Chapter = "—"
+10
View File
@@ -300,6 +300,10 @@
text-align: right;
}
.admin-sheet .tbl .c-act .ghost + .ghost {
margin-left: 12px;
}
.admin-sheet .tbl .trow > .confirm-row {
grid-column: 1 / -1;
padding: 10px 12px;
@@ -710,6 +714,12 @@
text-transform: uppercase;
}
.admin-sheet .dform .field {
display: flex;
gap: 10px;
margin-top: 8px;
}
.admin-sheet .pausebar {
display: flex;
align-items: center;
@@ -1,21 +1,35 @@
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
list row is one hop from it. Everything here is a Series-level fact plus
the anonymous Reader count; the intervention forms a later ticket adds
land inside the empty .detail-grid, so this page edits nothing. */}}
the anonymous Reader count. The Check now control lands in its own .dform
below the (empty) .detail-grid; the pending marker rides the meta line
with the other marks. */}}
{{define "series-detail"}}
<a class="ghost detail-back" href="/admin/series">← Series</a>
<h1 class="detail-title">{{.Title}}</h1>
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
{{else}}<div class="cover"></div>{{end}}
<div class="detail-meta">
{{template "series-detail-meta" .}}
<div class="detail-grid"></div>
{{if .CanPoll}}
<div class="dform">
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
</div>
{{end}}
{{end}}
{{/* series-detail-meta is the meta line, and the answer a Check now press on
the detail page swaps into its place: the same marks, re-rendered after
the stamp so the pending marker shows. */}}
{{define "series-detail-meta"}}
<div class="detail-meta" id="detail-meta">
<span>ch {{.Chapter}}</span>
<span>checked {{.Checked}}</span>
<span>{{.Readers}} readers</span>
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
</div>
<div class="detail-grid"></div>
{{end}}
+18 -11
View File
@@ -23,17 +23,7 @@
{{if .Rows}}
<div class="tbl series">
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
{{range .Rows}}
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a></span>
<span class="c-site site-{{.Site}}">{{.Site}}</span>
<span class="c-ch">{{.Ch}}</span>
<span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span>
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
<span class="c-act"></span>
</div>
{{end}}
{{range .Rows}}{{template "series-row" .}}{{end}}
</div>
<div class="pager">
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
@@ -44,3 +34,20 @@
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
{{end}}
{{end}}
{{/* series-row is one Series list row, and the answer a Check now press swaps
into the row's place (hx-target="closest .trow"): it must render the
pending marker the press created. The control is absent on a Series with
no page to fetch and on an orphan, so the owner is never offered a button
that can never do anything. */}}
{{define "series-row"}}
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a>{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}</span>
<span class="c-site site-{{.Site}}">{{.Site}}</span>
<span class="c-ch">{{.Ch}}</span>
<span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span>
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}</span>
</div>
{{end}}
+202 -3
View File
@@ -2355,10 +2355,15 @@ func TestSeriesListRowShape(t *testing.T) {
if !strings.Contains(body, `class="c-site site-asura"`) {
t.Errorf("the site cell lacks its site class:\n%s", body)
}
// The action cell is present and empty for the sibling ticket, and no
// Remove control or confirm row renders in this batch.
// The action cell carries the Check now control on pollable rows and is
// empty on the orphan (no URL, no Readers — a button that can never do
// anything is not offered), and no Remove control or confirm row renders
// in this batch.
if !strings.Contains(body, `<span class="c-act"></span>`) {
t.Errorf("the action cell is not present and empty:\n%s", body)
t.Errorf("the orphan's action cell is not present and empty:\n%s", body)
}
if got := strings.Count(body, "Check now"); got != 2 {
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
}
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
@@ -2837,3 +2842,197 @@ func TestOverviewPerSiteTableLinksToNarrowedLists(t *testing.T) {
}
}
}
// seriesDetailPage drives one Series detail request as the owner and returns
// the rendered body.
func seriesDetailPage(t *testing.T, srv http.Handler, st *store.Store, key string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code)
}
return rr.Body.String()
}
// Pressing Check now answers with freshly rendered markup showing the pending
// marker: the list row (or the detail meta) re-rendered after the stamp, so
// the figures describe the state after the press, not before (issue #146).
func TestSeriesPollRoundTrip(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:x", Site: "asura", SeriesID: "x",
Title: "Title of asura:x", SeriesURL: "https://asurascans.com/comics/x",
})
// List surface: the row anchor swaps its own row.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `class="trow`) {
t.Errorf("poll response is not the freshly rendered row:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">requested `) {
t.Errorf("poll response lacks the pending marker:\n%s", body)
}
// The press answers with the row's own band parity (hx-vals), so the swap
// keeps the zebra alternation.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader("band=1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll (banded) status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `class="trow`) || !strings.Contains(rr.Body.String(), ` band"`) {
t.Errorf("banded press answer lost the zebra parity:\n%s", rr.Body.String())
}
// Detail surface: the meta fragment carries the same marker.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
req.Header.Set("HX-Target", "detail-meta")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll (detail) status = %d, want 200", rr.Code)
}
body = rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) || !strings.Contains(body, "requested ") {
t.Errorf("detail response lacks the meta fragment with the pending marker:\n%s", body)
}
// The request is durable: the list shows the pending marker, which is the
// same row the press's answer rendered.
body = adminSeriesPage(t, router, st, "")
if !strings.Contains(body, "requested ") {
t.Errorf("the list does not show the pending marker after the press:\n%s", body)
}
}
// The Check now control is hidden on a Series with no page to fetch and on
// one no Reader holds — the owner is never offered a button that can never do
// anything — and present otherwise, on both the list row and the detail page.
func TestSeriesPollControlVisibility(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nourl", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
// List: exactly the pollable row offers the control.
body := adminSeriesPage(t, router, st, "")
if got := strings.Count(body, "Check now"); got != 1 {
t.Errorf("list offers Check now %d times, want 1 (only the pollable row):\n%s", got, body)
}
// Detail pages: the pollable row offers it, the other two do not.
for _, tc := range []struct {
key string
want bool
}{
{"asura:ok", true},
{"asura:nourl", false},
{"asura:orphan", false},
} {
body := seriesDetailPage(t, router, st, tc.key)
if got := strings.Contains(body, "Check now"); got != tc.want {
t.Errorf("%s detail offers Check now = %v, want %v", tc.key, got, tc.want)
}
}
}
// The pending marker ages and never expires: an old unanswered request still
// renders its marker with an old age, and a second press re-stamps the
// request time.
func TestSeriesPollMarkerAgesAndNeverExpires(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:x", url: "u", checkedAt: 0, bookmarks: 1})
// A five-day-old request, never checked: pending, with no expiry.
if _, err := db.Exec(
`UPDATE series SET force_poll_at = $1 WHERE site = 'asura' AND series_id = 'x'`,
time.Now().Add(-5*24*time.Hour).UnixMilli()); err != nil {
t.Fatalf("seed force stamp: %v", err)
}
router := newRouter(st, testConfig())
body := adminSeriesPage(t, router, st, "")
if !strings.Contains(body, "requested 5d ago") {
t.Errorf("old request does not render its aged marker:\n%s", body)
}
// A second press re-stamps: the marker reads fresh again.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "requested 1m ago") {
t.Errorf("re-stamp does not re-age the marker:\n%s", rr.Body.String())
}
}
// A malformed key is a 400 and an unknown key a 404 — neither is a 500, and
// neither reaches the store as an unvalidated write.
func TestSeriesPollRejectsBadKeys(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
for _, tc := range []struct {
path string
want int
}{
{"/admin/series/nocolon/poll", http.StatusBadRequest},
{"/admin/series/:x/poll", http.StatusBadRequest},
{"/admin/series/asura:/poll", http.StatusBadRequest},
{"/admin/series/asura:ghost/poll", http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want)
}
}
}
// Form bodies on the action route are capped the way the API path caps them:
// an oversized body is a 400, not a memory grant.
func TestSeriesPollCapsBody(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:x", Site: "asura", SeriesID: "x",
Title: "Title of asura:x", SeriesURL: "u",
})
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader(big))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("oversized body status = %d, want 400", rr.Code)
}
// Nothing was stamped: the list still shows no pending marker.
if body := adminSeriesPage(t, router, st, ""); strings.Contains(body, "requested ") {
t.Errorf("an oversized body still stamped the request:\n%s", body)
}
}