From 030ffdc26ec228b8322b1b4a191f8d5224634c44 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 16:54:54 +0700 Subject: [PATCH 01/16] feat: split admin surface into bookmarkable pages (#138) --- backend/internal/web/admin.go | 37 +- backend/internal/web/static/admin.css | 692 ++++++++++++++++++++++ backend/internal/web/static/style.css | 69 --- backend/internal/web/templates/admin.html | 43 +- backend/web_test.go | 62 +- 5 files changed, 795 insertions(+), 108 deletions(-) create mode 100644 backend/internal/web/static/admin.css diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 5ae53e3..3afcaa1 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -18,8 +18,10 @@ type LaneReporter interface { LaneStatus() latest.Status } -// adminView is what the administrative page and the roster fragment receive. +// adminView is the shared shell data for an administrative page and the roster +// fragment returned after a Reader action. type adminView struct { + Page string Readers []store.ReaderSummary // OwnerID travels with the roster so it can tell the owner's own row from // the Readers they may act on. @@ -84,6 +86,9 @@ type adminRoute struct { func (h *Handler) adminRoutes() []adminRoute { return []adminRoute{ {"GET /admin", h.admin}, + {"GET /admin/lanes", h.adminLanes}, + {"GET /admin/readers", h.adminReaders}, + {"GET /admin/series", h.adminSeries}, {"GET /ui/admin/lanes", h.uiLanes}, {"POST /readers/{id}/revoke", h.revokeReaderSessions}, {"POST /readers/{id}/clear-marks", h.clearReaderMarks}, @@ -116,19 +121,35 @@ func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc { }) } -// admin renders the owner's page: the Reader roster and Poll Lane status. +// admin renders the Overview shell. Its data arrives in later admin tickets. func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { + h.renderAdmin(w, adminView{Page: "overview"}) +} + +// adminLanes renders the page that hosts the live Lane fragment. +func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) { + h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()}) +} + +// adminReaders renders the Reader roster on its own bookmarkable page. +func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) { readers, err := h.store.Readers() if err != nil { - log.Printf("admin: %v", err) + log.Printf("admin readers: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - h.render(w, http.StatusOK, "admin", adminView{ - Readers: readers, - OwnerID: h.store.OwnerID(), - Lanes: h.lanesView(), - }) + h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()}) +} + +// adminSeries renders the Series shell. Its data arrives in a later admin +// ticket. +func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) { + h.renderAdmin(w, adminView{Page: "series"}) +} + +func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) { + h.render(w, http.StatusOK, "admin", view) } // uiLanes answers the status block's own refresh. Only the block refreshes on a diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css new file mode 100644 index 0000000..3c0d311 --- /dev/null +++ b/backend/internal/web/static/admin.css @@ -0,0 +1,692 @@ +:root { + --measure-wide: 1080px; +} + +@media (prefers-color-scheme: light) { + :root { + --measure-wide: 1080px; + } +} + +.admin-sheet { + max-width: var(--measure-wide); +} + +.admin-sheet .brand em { + color: var(--patina); +} + +.admin-sheet .brand .mark > g > g:last-child { + stroke: var(--patina); +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 18px; + margin-left: auto; +} + +.navrow { + display: flex; + gap: 18px; + padding: 2px 20px 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + border-bottom: 1px solid var(--rule); +} + +.navrow::-webkit-scrollbar { + display: none; +} + +.navrow a { + flex: none; + display: flex; + align-items: center; + min-width: 44px; + padding: 8px 0 12px; + color: var(--mute); + font: 400 17px var(--font-display); + white-space: nowrap; +} + +.navrow a:hover { + color: var(--paper-dim); +} + +.navrow a.active { + color: var(--paper); + border-bottom: 2px solid var(--paper); + margin-bottom: -1px; +} + +.admin-page { + padding: 0 20px 40px; +} + +.admin-page > .sec, +.admin-sheet .readers h2, +.admin-sheet .lanes h2 { + position: relative; + margin: 0; + padding: 26px 0 8px; + font: 500 11px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-page > .sec::before, +.admin-sheet .readers h2::before, +.admin-sheet .lanes h2::before { + content: ""; + position: absolute; + top: 14px; + left: 0; + width: 34px; + height: 2px; + background: var(--patina); +} + +.admin-sheet .readers, +.admin-sheet .lanes { + margin: 0; + padding: 0 0 16px; + border-bottom: none; +} + +.admin-sheet .readerlist, +.admin-sheet .lanelist { + margin: 0; + padding: 0; + list-style: none; +} + +.admin-sheet .readerlist li, +.admin-sheet .lanelist li { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 16px; + min-height: 48px; + padding: 10px 0; + border-top: 1px solid var(--rule); +} + +.admin-sheet .reader-actions { + display: flex; + flex: 0 0 auto; + gap: 18px; + margin-left: auto; + white-space: nowrap; +} + +.admin-sheet .reader-actions .ghost, +.admin-sheet .c-act .ghost { + font-size: 12px; + color: var(--patina); +} + +.admin-sheet .reader-actions .ghost.danger, +.admin-sheet .c-act .ghost.danger { + color: var(--danger); +} + +.admin-sheet .readerlist form { + margin: 0; +} + +.admin-sheet .lane-browser { + padding: 12px 0 0; +} + +.admin-sheet .ghost.danger { + color: var(--danger); +} + +.admin-sheet .ghost.danger:hover { + color: var(--danger); + border-bottom-color: var(--danger); +} + +.admin-sheet .reader-id { + font: 500 15px/1.5 var(--font-mono); + letter-spacing: .01em; + color: var(--paper); +} + +.admin-sheet .reader-sessions, +.admin-sheet .reader-sightings, +.admin-sheet .reader-blocked, +.admin-sheet .lane-fact, +.admin-sheet .lane-mark { + font: 500 13px/1.4 var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; +} + +.admin-sheet .reader-sessions { + color: var(--paper-dim); +} + +.admin-sheet .reader-sightings, +.admin-sheet .lane-fact { + color: var(--mute); +} + +.admin-sheet .reader-blocked, +.admin-sheet .lane-mark { + color: var(--patina); +} + +.admin-sheet .lane-site { + font: 400 19px/1.2 var(--font-display); + color: var(--paper-dim); +} + +.admin-sheet .lanelist li.attention .lane-site { + color: var(--danger); +} + +/* A single grid keeps row rules continuous; cell padding supplies gutters. */ +.admin-sheet .tbl { + display: grid; + grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px; + column-gap: 0; + font-variant-numeric: tabular-nums; +} + +.admin-sheet .tbl .thead { + display: contents; +} + +.admin-sheet .tbl .thead > * { + padding: 10px 14px 8px 0; + border-bottom: 1px solid var(--rule); + font: 500 12px/1 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-sheet .tbl .trow { + display: contents; +} + +.admin-sheet .tbl .trow > * { + padding: 11px 14px 11px 0; + border-bottom: 1px solid var(--rule); + font: 500 15px/1.5 var(--font-mono); + letter-spacing: .01em; + color: var(--paper-dim); +} + +.admin-sheet .tbl .thead > *:last-child, +.admin-sheet .tbl .trow > *:last-child { + padding-right: 0; +} + +.admin-sheet .tbl .c-title, +.admin-sheet .tbl .c-site { + font: 400 18px/1.35 var(--font-display); + letter-spacing: 0; + color: var(--paper); +} + +.admin-sheet .tbl .c-title a:hover, +.admin-sheet .tbl .c-act .ghost:hover { + color: var(--patina); +} + +.admin-sheet .tbl .c-ch, +.admin-sheet .tbl .c-rd { + text-align: right; + padding-right: 26px; +} + +.admin-sheet .tbl .c-act { + text-align: right; +} + +.admin-sheet .tbl .trow > .confirm-row { + grid-column: 1 / -1; + padding: 10px 12px; + border-bottom: none; +} + +.admin-sheet .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(232px, 1fr)); + margin: 4px 0 0; + border-bottom: 1px solid var(--rule); +} + +.admin-sheet .stat { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 11px 24px 11px 0; +} + +.admin-sheet .stat .lbl { + font: 500 13px/1.3 var(--font-mono); + letter-spacing: .1em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-sheet .stat .fig { + font: 500 19px/1 var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--paper); +} + +.admin-sheet .stat .fig.zero { + color: var(--mute); +} + +.admin-sheet .stat a.fig:hover { + color: var(--patina); + border-bottom: 1px solid var(--patina); +} + +.admin-sheet .mark, +.admin-sheet .mark-faint { + font: 500 13px/1 var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; + white-space: nowrap; +} + +.admin-sheet .mark { + color: var(--patina); +} + +.admin-sheet .mark-faint { + color: var(--mute-2); +} + +.admin-sheet .mark.bad { + color: var(--danger); +} + +.admin-sheet .tbl.sites { + grid-template-columns: 170px repeat(4, 106px) minmax(180px, 1fr); +} + +.admin-sheet .tbl.sites .c-site, +.admin-sheet .tbl.lanes .c-site { + font: 400 18px/1.35 var(--font-display); + letter-spacing: 0; + color: var(--paper); +} + +.admin-sheet .tbl.sites .c-state { + color: var(--patina); + white-space: nowrap; +} + +.admin-sheet .tbl.sites .c-state.bad, +.admin-sheet .tbl.lanes .c-skip .bad, +.admin-sheet .tbl.lanes .trow.attention .c-site { + color: var(--danger); +} + +.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5), +.admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) { + padding-right: 0; + text-align: center; +} + +.admin-sheet .tbl.lanes { + grid-template-columns: 150px 62px 88px 66px 118px minmax(0, 1fr) 168px; +} + +.admin-sheet .tbl.lanes .c-skip { + white-space: normal; +} + +.admin-sheet .tbl.lanes .c-skip > * { + white-space: nowrap; +} + +.admin-sheet .tbl.lanes .c-ctrl { + text-align: right; +} + +.admin-sheet .tbl.lanes .thead > *:nth-child(n+2):nth-child(-n+4), +.admin-sheet .tbl.lanes .trow > *:nth-child(n+2):nth-child(-n+4) { + padding-right: 26px; + text-align: right; +} + +.admin-sheet .tbl.series { + grid-template-columns: 150px 84px 104px 76px minmax(120px, 1fr) 212px; + row-gap: 4px; +} + +.admin-sheet .tbl.series .trow { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + padding: 13px 20px 14px; +} + +.admin-sheet .tbl.series .trow.band { + background: var(--hover); +} + +.admin-sheet .tbl.series .trow > * { + padding: 0 14px 0 0; + border-bottom: none; +} + +.admin-sheet .tbl.series .c-title { + display: flex; + grid-column: 1 / -1; + align-items: baseline; + gap: 16px; + padding: 0 0 4px; +} + +.admin-sheet .tbl.series .c-title .mark { + margin-left: auto; +} + +.admin-sheet .filterbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 22px; + padding: 16px 0 14px; + border-bottom: 1px solid var(--rule); +} + +.admin-sheet .fsel { + display: flex; + align-items: baseline; + gap: 10px; +} + +.admin-sheet .fsel > span { + font: 500 11px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-sheet .fsel select, +.admin-sheet .pausebar select { + color-scheme: dark; + background: var(--ink); + border: none; + border-bottom: 1px solid var(--field-line); + color: var(--paper); + font: 400 16px/1.4 var(--font-display); + padding: 4px 4px 5px 0; +} + +.admin-sheet .fsel select:hover, +.admin-sheet .pausebar select:hover { + border-bottom-color: var(--patina); +} + +.admin-sheet .segrow { + display: inline-flex; + gap: 2px; +} + +.admin-sheet .segrow a { + padding: 6px 11px 7px; + border-bottom: 2px solid transparent; + color: var(--mute); + font: 500 14px/1 var(--font-mono); +} + +.admin-sheet .segrow a.active { + border-bottom-color: var(--patina); + color: var(--patina); +} + +.admin-sheet .listhead { + display: flex; + align-items: baseline; + gap: 12px; + padding: 16px 0 4px; + color: var(--paper); + font: 400 18px/1.2 var(--font-display); +} + +.admin-sheet .listhead .lbl { + color: var(--mute); +} + +.admin-sheet .listhead .lbl em { + color: var(--patina); + font-style: normal; +} + +.admin-sheet .pager { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0 0; + color: var(--mute-2); + font: 500 11px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; +} + +.admin-sheet .pager .pg.disabled { + color: var(--faint); + pointer-events: none; +} + +.admin-sheet .empty { + padding: 28px 0; + text-align: center; +} + +.admin-sheet .empty strong { + color: var(--paper); + font: 400 20px var(--font-display); +} + +.admin-sheet .empty p { + margin: 6px 0 0; + color: var(--mute); +} + +.admin-sheet .confirm-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--danger-wash); +} + +.admin-sheet .confirm-row span { + flex: 1 1 16ch; + color: var(--danger-soft); + font: 400 15px/1.3 var(--font-display); +} + +.admin-sheet .confirm-row div { + display: flex; + flex: none; + gap: 12px; + margin-left: auto; +} + +.admin-sheet .detail-back { + display: inline-block; + margin: 18px 0 0; +} + +.admin-sheet .detail-title { + margin: 10px 0 2px; + color: var(--paper); + font: 400 28px/1.25 var(--font-display); +} + +.admin-sheet .detail-key { + margin: 0; + color: var(--mute-2); + font: 500 11px/1.4 var(--font-mono); + letter-spacing: .08em; +} + +.admin-sheet .detail-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin: 10px 0 0; + color: var(--mute-2); + font: 500 12px/1.5 var(--font-mono); + letter-spacing: .08em; + text-transform: uppercase; +} + +.admin-sheet .cover { + width: 160px; + aspect-ratio: 3 / 4; + display: flex; + align-items: center; + justify-content: center; + margin: 18px 0 4px; + background: var(--hatch); + color: var(--mute-2); + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; +} + +.admin-sheet .detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 28px; +} + +.admin-sheet .dform { + padding: 14px 0 0; +} + +.admin-sheet .dform h3 { + margin: 0 0 4px; + color: var(--mute-2); + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; +} + +.admin-sheet .pausebar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +@media (max-width: 719px) { + .admin-sheet .topbar { + flex-wrap: wrap; + row-gap: 12px; + } + + .admin-sheet .brand { + flex: 1 1 100%; + } + + .admin-sheet .topbar-actions { + margin-left: auto; + } +} + +@media (max-width: 1019px) { + .admin-sheet .tbl.lanes { + display: block; + } + + .admin-sheet .tbl.lanes .thead { + display: none; + } + + .admin-sheet .tbl.lanes .trow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 16px; + padding: 12px 0; + border-bottom: 1px solid var(--rule); + } + + .admin-sheet .tbl.lanes .trow > * { + padding: 0; + border-bottom: none; + text-align: left; + } + + .admin-sheet .tbl.lanes .c-site { + width: 100%; + padding-bottom: 2px; + } + + .admin-sheet .tbl.lanes .c-ctrl { + margin-left: auto; + text-align: right; + } +} + +@media (max-width: 899px) { + .admin-sheet .tbl, + .admin-sheet .tbl.lanes { + display: block; + } + + .admin-sheet .tbl .thead { + display: none; + } + + .admin-sheet .tbl .trow { + display: flex; + flex-wrap: wrap; + gap: 2px 10px; + padding: 11px 0; + border-bottom: 1px solid var(--rule); + } + + .admin-sheet .tbl .trow > * { + padding: 0; + border-bottom: none; + text-align: left; + } + + .admin-sheet .tbl .c-ch, + .admin-sheet .tbl .c-rd { + padding-right: 0; + text-align: left; + } + + .admin-sheet .tbl .c-title { + width: 100%; + padding-bottom: 2px; + } + + .admin-sheet .tbl.series .trow { + display: flex; + } + + .admin-sheet .tbl.series .trow > * { + padding: 0; + } + + .admin-sheet .detail-grid { + grid-template-columns: 1fr; + } + + .admin-sheet .tbl .c-act { + margin-left: auto; + } +} diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index 4a1ba46..91c1526 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -296,69 +296,6 @@ button { cursor: pointer; } letter-spacing: .04em; } -/* ---- admin page: two sections on the same measured sheet, no cards ---- - The reading page is a list of series; this is a list of facts. Both are - sheets of hairline-separated rows, so the roster keeps the shape it had as - a fold-out and the Lane block copies it. */ -.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); } -.readers h2, .lanes h2 { - margin: 0; - padding: 8px 0; - font: 500 10px/1 var(--font-mono); - letter-spacing: .2em; - text-transform: uppercase; - color: var(--mute-2); -} -.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; } -.readerlist li, .lanelist li { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 16px; - min-height: 44px; - border-top: 1px solid var(--rule); -} -.reader-actions { display: flex; gap: 18px; margin-left: auto; } -.readerlist form { margin: 0; } -.reader-id { - font: 500 13px/1.4 var(--font-mono); - letter-spacing: .04em; - color: var(--paper); -} -.reader-sessions { - font: 500 10px/1 var(--font-mono); - letter-spacing: .14em; - text-transform: uppercase; - color: var(--mute); -} -.reader-sightings, .lane-fact { - font: 500 10px/1 var(--font-mono); - letter-spacing: .14em; - text-transform: uppercase; - color: var(--mute-2); -} -/* Two states the owner is meant to find rather than read for: a Reader whose - reports no longer defer a Poll, and a Lane that is not keeping its promise. - Both wear --patina — never ember, which means one thing, and never danger, - which is destruction. */ -.reader-blocked, .lane-mark { - font: 500 10px/1 var(--font-mono); - letter-spacing: .14em; - text-transform: uppercase; - color: var(--patina); -} -.lane-site { - font: 400 19px/1.2 var(--font-display); - color: var(--paper-dim); -} -/* The whole row leans patina when the Lane needs attention, so the scan is one - pass down the left edge rather than a read of every mark. */ -.lanelist li.attention .lane-site { color: var(--patina); } -.lane-browser { padding: 12px 0 0; } -/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for - the new-chapter signal. */ -.ghost.danger { color: var(--danger); } -.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); } .chrome { display: flex; flex-direction: column; } @@ -643,9 +580,6 @@ button { cursor: pointer; } box-shadow: inset 0 -2px 0 var(--ember); } .topbar form { margin-left: 18px; } -/* The admin page's topbar has no switch to fill the middle, so its back link - keeps company with Log out at the right edge instead of floating centre. */ -.topbar .back { margin-left: auto; } /* At phone width brand + switch + Log out do not fit on one line, so the switch takes its own row under the wordmark rather than pushing Log out off-screen. */ @@ -655,9 +589,6 @@ button { cursor: pointer; } .libswitch { order: 3; margin-left: 0; } .libswitch a { flex: 1; text-align: center; padding: 8px 14px; } .topbar form { margin-left: 12px; } - /* The admin page has no switch to take the second row, so its brand claims - the first outright and the back link keeps Log out company below. */ - .topbar:has(.back) .brand { flex: 1 1 100%; } } /* ---- action strip: full-width on a phone, hairline-divided cells ---- */ diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index f2f8e0d..1f60e20 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -1,7 +1,5 @@ -{{/* The owner's administrative page: everything that reaches past one Reader, - at its own address so it can be bookmarked rather than hunted for inside - the reading page. Owner-only at route registration (requireOwner), which - is why nothing in here re-tests who is asking. */}} +{{/* Every owner-only address shares this shell; page content stays behind its + bookmarkable route so the active tab survives a reload. */}} {{define "admin"}} @@ -12,27 +10,40 @@ BookmarkManager — Admin + -
+

{{template "mark" .}}BookmarkManager

- {{/* Back to the library, no switch: this page belongs to neither library, - and the ember-lit switch says which library you are reading. */}} - Library -
- -
+ + Library +
+ +
+
- {{/* The live region wraps the swapped block rather than being it: the - refresh replaces the section wholesale, and a region recreated on every - update is never announced. */}} -
{{template "lanes" .Lanes}}
+ - {{template "readers" .}} +
+ {{if eq .Page "lanes"}} +
{{template "lanes" .Lanes}}
+ {{else if eq .Page "readers"}} + {{template "readers" .}} + {{else if eq .Page "series"}} +

Series

+ {{else}} +

Overview

+ {{end}} +
diff --git a/backend/web_test.go b/backend/web_test.go index baf61c7..dcfd21d 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -640,10 +640,9 @@ type fakeLanes struct{ status latest.Status } func (f fakeLanes) LaneStatus() latest.Status { return f.status } -// The roster moved off the reading page onto its own address: the owner gets a -// link, everyone else gets nothing, and the page itself lists every Reader with -// the counters and the two controls. -func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) { +// Every admin address carries the same navigation, while the roster only lives +// on its own page and the other pages keep their shells independent. +func TestAdminPagesCarrySharedNavigation(t *testing.T) { router, st, _ := oauthWebTestServer(t) theirCookie := signInCookie(t, router) ownerCookie := sessionCookie(t, st) @@ -654,12 +653,11 @@ func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) { router.ServeHTTP(rr, req) body := rr.Body.String() if strings.Contains(body, `id="readers"`) { - t.Error("the reading page still carries the roster; it belongs on /admin") + t.Error("the reading page still carries the roster; it belongs on /admin/readers") } if !strings.Contains(body, `href="/admin"`) { t.Error("the owner's reading page offers no link to the admin page") } - req = httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(theirCookie) rr = httptest.NewRecorder() @@ -668,21 +666,55 @@ func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) { t.Error("a non-owner was offered the admin link") } - req = httptest.NewRequest(http.MethodGet, "/admin", nil) + for _, page := range []struct { + path string + name string + }{ + {"/admin", "Overview"}, + {"/admin/lanes", "Lanes"}, + {"/admin/readers", "Readers"}, + {"/admin/series", "Series"}, + } { + t.Run(page.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, page.path, nil) + req.AddCookie(ownerCookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200", page.path, rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `class="topbar-actions"`) { + t.Errorf("%s has no topbar action cluster:\n%s", page.path, body) + } + if !strings.Contains(body, `aria-label="Admin pages"`) { + t.Errorf("%s has no admin navigation:\n%s", page.path, body) + } + if strings.Count(body, `aria-current="page"`) != 1 { + t.Errorf("%s has %d active admin tabs, want 1:\n%s", page.path, strings.Count(body, `aria-current="page"`), body) + } + if !strings.Contains(body, page.name) { + t.Errorf("%s does not name its active page %q:\n%s", page.path, page.name, body) + } + if !strings.Contains(body, `href="/static/admin.css"`) { + t.Errorf("%s does not load the admin foundation stylesheet", page.path) + } + if page.name == "Lanes" && strings.Count(body, `hx-trigger="every 30s"`) != 1 { + t.Errorf("%s has %d Lane timers, want exactly 1", page.path, strings.Count(body, `hx-trigger="every 30s"`)) + } + }) + } + + req = httptest.NewRequest(http.MethodGet, "/admin/readers", nil) req.AddCookie(ownerCookie) rr = httptest.NewRecorder() router.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("GET /admin status = %d, want 200", rr.Code) - } body = rr.Body.String() for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} { if !strings.Contains(body, want) { - t.Errorf("admin page lacks %q:\n%s", want, body) + t.Errorf("readers page lacks %q:\n%s", want, body) } } - // Exactly one revocable row: the other Reader's. The owner's own row carries - // the same session count and no button. if n := strings.Count(body, "/revoke"); n != 1 { t.Fatalf("roster has %d revoke controls, want 1 (the owner's own row must have none):\n%s", n, body) } @@ -845,7 +877,7 @@ func TestAdminPageWithoutAPollerSaysSo(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { router, st, _ := oauthWebTestServer(t, tc.lanes...) - req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil) req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -881,7 +913,7 @@ func TestOwnerClearsReaderMarks(t *testing.T) { t.Fatalf("mark reader: %v", err) } - req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req := httptest.NewRequest(http.MethodGet, "/admin/readers", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) -- 2.52.0 From fd1131d11df54de6e9945d82f1219a7f568408c4 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 17:17:50 +0700 Subject: [PATCH 02/16] feat(store): persist poll lane state --- backend/AGENTS.md | 3 +- .../store/migrations/0012_poll_lanes.sql | 6 + .../store/migrations/0013_poll_passes.sql | 16 ++ backend/internal/store/store.go | 226 +++++++++++++++++- backend/internal/store/store_test.go | 145 +++++++++++ 5 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 backend/internal/store/migrations/0012_poll_lanes.sql create mode 100644 backend/internal/store/migrations/0013_poll_passes.sql diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 2fd68d6..fc91b34 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -31,8 +31,7 @@ image, TLS terminated by the reverse proxy so the service listens plain `:8080`. store must have that `TestMain` or it has no database at all. ### Reader-owned store — `internal/store`, `internal/token` - -Four tables; shape is in the migrations, behaviour in `Store`'s methods. +- The Reader-owned tables are `readers`, `bookmarks`, `series`, and `sessions`; auxiliary `covers`, `poll_lanes`, and `poll_passes` are also defined in the migrations. - **Credentials are derived, never stored.** `token.Token(TOKEN_KEY, discord_id, epoch)` is an HMAC; only its SHA-256 reaches `readers.token_sha256`. So install URLs diff --git a/backend/internal/store/migrations/0012_poll_lanes.sql b/backend/internal/store/migrations/0012_poll_lanes.sql new file mode 100644 index 0000000..ff56f12 --- /dev/null +++ b/backend/internal/store/migrations/0012_poll_lanes.sql @@ -0,0 +1,6 @@ +-- One durable state row per Poll Lane. Zero means no pause or refusal is set. +CREATE TABLE poll_lanes ( + site text NOT NULL PRIMARY KEY, + paused_until bigint NOT NULL DEFAULT 0, + refuse_until bigint NOT NULL DEFAULT 0 +); diff --git a/backend/internal/store/migrations/0013_poll_passes.sql b/backend/internal/store/migrations/0013_poll_passes.sql new file mode 100644 index 0000000..e5095ee --- /dev/null +++ b/backend/internal/store/migrations/0013_poll_passes.sql @@ -0,0 +1,16 @@ +-- Append-only Lane Pass log. Timestamps are unix milliseconds from the poller's clock. +CREATE TABLE poll_passes ( + site text NOT NULL, + ran_at bigint NOT NULL, + skip text NOT NULL, + due integer NOT NULL, + checked integer NOT NULL, + gap_ms bigint NOT NULL, + clamped boolean NOT NULL, + refused integer NOT NULL, + unreachable integer NOT NULL, + no_chapter integer NOT NULL, + unfetchable integer NOT NULL, + errors integer NOT NULL, + PRIMARY KEY (site, ran_at) +); diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index f102b63..869f603 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -90,6 +90,34 @@ type Series struct { readerCount int } +// LanePass is one Poll Lane's durable pass snapshot. Pause and refusal stamps +// are joined from poll_lanes on read; they are not pass facts. +type LanePass struct { + Site string + RanAt int64 + Skip string + Due, Checked int + GapMS int64 + Clamped bool + Refused, Unreachable, NoChapter int + Unfetchable, Errors int + PausedUntil, RefuseUntil int64 +} + +// SiteOutcomes is one Site's summed Lane Pass outcomes over a caller-supplied +// window. +type SiteOutcomes struct { + Site string + Refused, Unreachable, NoChapter int + Unfetchable, Errors int +} + +// LanePause is one persisted Lane pause stamp. +type LanePause struct { + Site string + PausedUntil int64 +} + // Key returns the canonical identity in bookmark-key form (":"), // used by the poller's logs and by tests asserting on the due queue. func (s Series) Key() string { return s.Site + ":" + s.SeriesID } @@ -188,9 +216,9 @@ const ( //go:embed migrations/*.sql var migrations embed.FS -// bookmarkColumns is the only value ever concatenated into query text. It is a -// compile-time constant; every request value is bound as a parameter. The -// series-owned fields are joined in from the series table, in scanBookmark +// These column lists are the only values ever concatenated into query text. +// They are compile-time constants; every request value is bound as a parameter. +// The series-owned fields are joined in from the series table, in scanBookmark // order, so the flat Bookmark reads back whole despite the split (ADR-0004). const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address, b.last_chapter, b.last_chapter_num, b.last_chapter_url, @@ -202,6 +230,10 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_add const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by` +const lanePassColumns = `p.site, p.ran_at, p.skip, p.due, p.checked, p.gap_ms, p.clamped, + p.refused, p.unreachable, p.no_chapter, p.unfetchable, p.errors, + COALESCE(l.paused_until, 0), COALESCE(l.refuse_until, 0)` + // Owner is the person running the service: the first Reader, seeded at startup // so a fresh deployment has a library before anyone logs in. The seed makes // sure exactly one readers row matches their Discord ID, carrying the SHA-256 @@ -613,6 +645,18 @@ func scanSeries(scan func(...any) error) (Series, error) { return sr, nil } +func scanLanePass(scan func(...any) error) (LanePass, error) { + var p LanePass + if err := scan( + &p.Site, &p.RanAt, &p.Skip, &p.Due, &p.Checked, &p.GapMS, &p.Clamped, + &p.Refused, &p.Unreachable, &p.NoChapter, &p.Unfetchable, &p.Errors, + &p.PausedUntil, &p.RefuseUntil, + ); err != nil { + return LanePass{}, err + } + return p, nil +} + // Close releases the underlying database handle. func (s *Store) Close() error { return s.db.Close() } @@ -934,6 +978,182 @@ func (s *Store) Delete(readerID int64, key string) error { return nil } +// RecordLanePass appends one pass and prunes every older row in the same +// transaction. retainBefore is supplied by the poller's clock. +func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin lane pass %s: %w", p.Site, err) + } + defer tx.Rollback() + if _, err := tx.Exec(` + INSERT INTO poll_passes + (site, ran_at, skip, due, checked, gap_ms, clamped, + refused, unreachable, no_chapter, unfetchable, errors) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + p.Site, p.RanAt, p.Skip, p.Due, p.Checked, p.GapMS, p.Clamped, + p.Refused, p.Unreachable, p.NoChapter, p.Unfetchable, p.Errors); err != nil { + return fmt.Errorf("insert lane pass %s at %d: %w", p.Site, p.RanAt, err) + } + if _, err := tx.Exec(`DELETE FROM poll_passes WHERE ran_at < $1`, retainBefore); err != nil { + return fmt.Errorf("prune lane passes before %d: %w", retainBefore, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit lane pass %s at %d: %w", p.Site, p.RanAt, err) + } + return nil +} + +// LatestLanePass returns the newest pass for one Site, with its current Lane +// state joined on. A Site without a pass has no durable snapshot yet. +func (s *Store) LatestLanePass(site string) (LanePass, bool, error) { + p, err := scanLanePass(s.db.QueryRow(`SELECT `+lanePassColumns+` + FROM poll_passes p + LEFT JOIN poll_lanes l ON l.site = p.site + WHERE p.site = $1 + ORDER BY p.ran_at DESC + LIMIT 1`, site).Scan) + if errors.Is(err, sql.ErrNoRows) { + return LanePass{}, false, nil + } + if err != nil { + return LanePass{}, false, fmt.Errorf("latest lane pass %s: %w", site, err) + } + return p, true, nil +} + +// LatestLanePasses returns the newest pass for each Site, with current Lane +// state joined on. Sites without a pass have no row yet. +func (s *Store) LatestLanePasses() ([]LanePass, error) { + rows, err := s.db.Query(`SELECT ` + lanePassColumns + ` + FROM ( + SELECT DISTINCT ON (site) + site, ran_at, skip, due, checked, gap_ms, clamped, + refused, unreachable, no_chapter, unfetchable, errors + FROM poll_passes + ORDER BY site, ran_at DESC + ) p + LEFT JOIN poll_lanes l ON l.site = p.site + ORDER BY p.site`) + if err != nil { + return nil, fmt.Errorf("query latest lane passes: %w", err) + } + defer rows.Close() + + out := []LanePass{} + for rows.Next() { + p, err := scanLanePass(rows.Scan) + if err != nil { + return nil, fmt.Errorf("scan latest lane pass: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// LanePassOutcomes sums the named outcomes for each Site at or after since. +// The window boundary is supplied by the caller; the store has no clock. +func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) { + rows, err := s.db.Query(` + SELECT site, SUM(refused), SUM(unreachable), SUM(no_chapter), + SUM(unfetchable), SUM(errors) + FROM poll_passes + WHERE ran_at >= $1 + GROUP BY site + ORDER BY site`, since) + if err != nil { + return nil, fmt.Errorf("query lane pass outcomes: %w", err) + } + defer rows.Close() + + out := []SiteOutcomes{} + for rows.Next() { + var outcomes SiteOutcomes + if err := rows.Scan( + &outcomes.Site, &outcomes.Refused, &outcomes.Unreachable, + &outcomes.NoChapter, &outcomes.Unfetchable, &outcomes.Errors, + ); err != nil { + return nil, fmt.Errorf("scan lane pass outcomes: %w", err) + } + out = append(out, outcomes) + } + return out, rows.Err() +} + +// SetLaneRefusal persists a Site's refusal backoff stamp without touching its +// pause. until is supplied by the caller's clock. +func (s *Store) SetLaneRefusal(site string, until int64) error { + if _, err := s.db.Exec(` + INSERT INTO poll_lanes (site, refuse_until) VALUES ($1, $2) + ON CONFLICT (site) DO UPDATE SET refuse_until = EXCLUDED.refuse_until`, site, until); err != nil { + return fmt.Errorf("set lane refusal %s: %w", site, err) + } + return nil +} + +// PauseLane persists a bounded pause. The caller must ensure until is after +// its current timestamp; the store has no clock and rejects only the invalid +// zero and negative sentinels. +func (s *Store) PauseLane(site string, until int64) error { + if until <= 0 { + return fmt.Errorf("pause lane %s: expiry must be positive", site) + } + if _, err := s.db.Exec(` + INSERT INTO poll_lanes (site, paused_until) VALUES ($1, $2) + ON CONFLICT (site) DO UPDATE SET paused_until = EXCLUDED.paused_until`, site, until); err != nil { + return fmt.Errorf("pause lane %s: %w", site, err) + } + return nil +} + +// ResumeLane clears only the pause stamp and keeps the Lane state row, along +// with any refusal stamp already persisted on it. +func (s *Store) ResumeLane(site string) error { + if _, err := s.db.Exec( + `UPDATE poll_lanes SET paused_until = 0 WHERE site = $1`, site); err != nil { + return fmt.Errorf("resume lane %s: %w", site, err) + } + return nil +} + +// PausedLanes returns Lane rows with a nonzero pause stamp. Expiry comparison +// stays with the caller because the store is deliberately clockless. +func (s *Store) PausedLanes() ([]LanePause, error) { + rows, err := s.db.Query(` + SELECT site, paused_until + FROM poll_lanes + WHERE paused_until > 0 + ORDER BY site`) + if err != nil { + return nil, fmt.Errorf("query paused lanes: %w", err) + } + defer rows.Close() + + out := []LanePause{} + for rows.Next() { + var pause LanePause + if err := rows.Scan(&pause.Site, &pause.PausedUntil); err != nil { + return nil, fmt.Errorf("scan paused lane: %w", err) + } + out = append(out, pause) + } + return out, rows.Err() +} + +// LanePausedUntil reads a Site's pause stamp. A missing state row is the +// default unpaused state. +func (s *Store) LanePausedUntil(site string) (int64, error) { + var until int64 + err := s.db.QueryRow(`SELECT paused_until FROM poll_lanes WHERE site = $1`, site).Scan(&until) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("lane pause %s: %w", site, err) + } + return until, nil +} + // DueForLatestCheck returns one Site's series whose server-side // latest-chapter check has aged past cutoffMs, ordered by how many bookmarks // reference them (descending) then least-recently-checked first. One Site per diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 1b4a83b..fac25e4 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -1584,3 +1584,148 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) { t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err) } } +func TestRecordLanePassPrunesBeforeInsertCutoff(t *testing.T) { + s := newTestStore(t) + for _, pass := range []LanePass{ + {Site: "asura", RanAt: 99}, + {Site: "asura", RanAt: 100}, + } { + if err := s.RecordLanePass(pass, 100); err != nil { + t.Fatalf("RecordLanePass(%d): %v", pass.RanAt, err) + } + } + + if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 200}, 100); err != nil { + t.Fatalf("RecordLanePass(200): %v", err) + } + var count int + if err := s.db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, "asura").Scan(&count); err != nil { + t.Fatalf("count passes: %v", err) + } + if count != 2 { + t.Fatalf("retained passes = %d, want 2", count) + } + if _, ok, err := s.LatestLanePass("asura"); err != nil || !ok { + t.Fatalf("LatestLanePass = ok %v, err %v; want latest row", ok, err) + } +} + +func TestLatestLanePassesKeepsNewestPerSiteAndJoinsState(t *testing.T) { + s := newTestStore(t) + for _, pass := range []LanePass{ + {Site: "asura", RanAt: 100, Due: 1}, + {Site: "asura", RanAt: 200, Skip: "due-query", Due: 2, Checked: 3, GapMS: 4000, Clamped: true}, + {Site: "demonic", RanAt: 150, Due: 4}, + } { + if err := s.RecordLanePass(pass, -1); err != nil { + t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err) + } + } + if err := s.PauseLane("asura", 1234); err != nil { + t.Fatalf("PauseLane: %v", err) + } + if err := s.SetLaneRefusal("asura", 5678); err != nil { + t.Fatalf("SetLaneRefusal: %v", err) + } + + got, err := s.LatestLanePasses() + if err != nil { + t.Fatalf("LatestLanePasses: %v", err) + } + if len(got) != 2 { + t.Fatalf("latest passes = %d, want one per Site", len(got)) + } + bySite := map[string]LanePass{} + for _, pass := range got { + bySite[pass.Site] = pass + } + asura := bySite["asura"] + if asura.RanAt != 200 || asura.Skip != "due-query" || asura.Due != 2 || asura.Checked != 3 || asura.GapMS != 4000 || !asura.Clamped || + asura.PausedUntil != 1234 || asura.RefuseUntil != 5678 { + t.Fatalf("asura latest pass = %+v, want newest pass and joined state", asura) + } + if demonic := bySite["demonic"]; demonic.RanAt != 150 || demonic.Due != 4 { + t.Fatalf("demonic latest pass = %+v, want its only pass", demonic) + } +} + +func TestLanePassOutcomesSumsWindow(t *testing.T) { + s := newTestStore(t) + for _, pass := range []LanePass{ + {Site: "asura", RanAt: 99, Refused: 1, Unreachable: 2, NoChapter: 3, Unfetchable: 4, Errors: 5}, + {Site: "asura", RanAt: 100, Refused: 2, Unreachable: 3, NoChapter: 4, Unfetchable: 5, Errors: 6}, + {Site: "asura", RanAt: 200, Refused: 3, Unreachable: 4, NoChapter: 5, Unfetchable: 6, Errors: 7}, + {Site: "demonic", RanAt: 150, Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12}, + } { + if err := s.RecordLanePass(pass, -1); err != nil { + t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err) + } + } + + got, err := s.LanePassOutcomes(100) + if err != nil { + t.Fatalf("LanePassOutcomes: %v", err) + } + if len(got) != 2 { + t.Fatalf("outcome Sites = %d, want 2", len(got)) + } + bySite := map[string]SiteOutcomes{} + for _, outcomes := range got { + bySite[outcomes.Site] = outcomes + } + if want := (SiteOutcomes{Site: "asura", Refused: 5, Unreachable: 7, NoChapter: 9, Unfetchable: 11, Errors: 13}); bySite["asura"] != want { + t.Fatalf("asura outcomes = %+v, want %+v", bySite["asura"], want) + } + if want := (SiteOutcomes{Site: "demonic", Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12}); bySite["demonic"] != want { + t.Fatalf("demonic outcomes = %+v, want %+v", bySite["demonic"], want) + } +} + +func TestLaneStatePauseResumeAndRefusal(t *testing.T) { + s := newTestStore(t) + for _, until := range []int64{0, -1} { + if err := s.PauseLane("asura", until); err == nil { + t.Fatalf("PauseLane(%d) accepted a non-future expiry", until) + } + } + if err := s.PauseLane("asura", 2000); err != nil { + t.Fatalf("PauseLane: %v", err) + } + if err := s.SetLaneRefusal("asura", 3000); err != nil { + t.Fatalf("SetLaneRefusal: %v", err) + } + if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 1}, -1); err != nil { + t.Fatalf("RecordLanePass: %v", err) + } + if got, err := s.LanePausedUntil("asura"); err != nil || got != 2000 { + t.Fatalf("LanePausedUntil = %d, %v; want 2000", got, err) + } + paused, err := s.PausedLanes() + if err != nil { + t.Fatalf("PausedLanes: %v", err) + } + if len(paused) != 1 || paused[0] != (LanePause{Site: "asura", PausedUntil: 2000}) { + t.Fatalf("PausedLanes = %+v, want asura/2000", paused) + } + + if err := s.ResumeLane("asura"); err != nil { + t.Fatalf("ResumeLane: %v", err) + } + latest, ok, err := s.LatestLanePass("asura") + if err != nil || !ok || latest.RefuseUntil != 3000 { + t.Fatalf("latest refusal after resume = %+v, ok=%v, err=%v; want 3000 preserved", latest, ok, err) + } + if got, err := s.LanePausedUntil("asura"); err != nil || got != 0 { + t.Fatalf("LanePausedUntil after resume = %d, %v; want 0", got, err) + } + if paused, err := s.PausedLanes(); err != nil || len(paused) != 0 { + t.Fatalf("PausedLanes after resume = %+v, %v; want empty", paused, err) + } + var rows int + if err := s.db.QueryRow(`SELECT count(*) FROM poll_lanes WHERE site = $1`, "asura").Scan(&rows); err != nil { + t.Fatalf("count lane state: %v", err) + } + if rows != 1 { + t.Fatalf("lane state rows after resume = %d, want 1", rows) + } +} -- 2.52.0 From e0b9063d9ef69a06df396e9da732ca064cb4484f Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 18:20:59 +0700 Subject: [PATCH 03/16] feat(store): cross-series admin read model with privacy in the projection (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeriesPage returns one 50-row page of Series matching one of eight named hygiene filters (all, no_series_url, never_read_a_chapter, no_readers, never_checked, stale, no_cover, reader_report), with a window-count total; SeriesShapes returns the per-Site aggregate, one grouped pass. The compound filter value object takes Site, Kind, Name, a caller-supplied staleness cutoff, and a 1-based page. The privacy boundary lives in the projection: adminSeriesColumns never selects latest_raised_by, and AdminSeries has no field for it — a SQL-computed boolean is all that crosses. LEFT JOIN surfaces orphans (reader_count 0); (site, series_id) tie-breaks the zero-stamp boundary so pages stay stable. Also lands 0014: the series(latest_checked_at) index and series.force_poll_at, both expand-step schema for later tickets in the series. --- backend/internal/store/admin.go | 271 ++++++++++++++ backend/internal/store/admin_test.go | 350 ++++++++++++++++++ .../migrations/0014_admin_read_model.sql | 11 + 3 files changed, 632 insertions(+) create mode 100644 backend/internal/store/admin.go create mode 100644 backend/internal/store/admin_test.go create mode 100644 backend/internal/store/migrations/0014_admin_read_model.sql diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go new file mode 100644 index 0000000..24e05fd --- /dev/null +++ b/backend/internal/store/admin.go @@ -0,0 +1,271 @@ +package store + +import ( + "database/sql" + "fmt" + "strconv" + "strings" +) + +// Series filter names (issue #140), ordered permanent-then-fixable — the +// repairs nothing will ever undo first, the ones a Poll can make right after. +// A name is the repair a row needs, not the SQL that finds it; the values are +// the wire form the Series list URL carries (#142). "all" is the absent and +// unknown case: every Series. +const ( + SeriesFilterAll = "all" + SeriesFilterNoURL = "no_series_url" + SeriesFilterNoChapter = "never_read_a_chapter" + SeriesFilterNoReaders = "no_readers" + SeriesFilterNeverChecked = "never_checked" + SeriesFilterStale = "stale" + SeriesFilterNoCover = "no_cover" + SeriesFilterReaderReport = "reader_report" +) + +// SeriesFilter is one named hygiene predicate over the whole library. Site +// and Kind narrow the row read; Name picks the predicate; Cutoff is the +// staleness boundary the "stale" filter compares against, supplied by the +// caller's clock — the store has no clock; Page is 1-based. +type SeriesFilter struct { + Site string // "" = every Site + Kind string // "" = both library buckets' series + Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll + Cutoff int64 // unix ms; "stale" reads it, the store never does + Page int // 1-based page of the row read; default 1 +} + +// adminSeriesColumns is the owner's library-wide Series projection in +// scanAdminSeries order. It is the privacy boundary: a Series' row carries +// the Reader id that raised its Latest Chapter (latest_raised_by), and that id +// 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` + +// raisedByReaderAnswer answers "did a Reader's report set this number" without +// naming which Reader. Kept apart from adminSeriesColumns so the column list — +// the shape scanAdminSeries is fed — stays free of the Sighting-raiser +// identity, and the owner learns which rows to act on and nothing about the +// Reader behind them. +const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader` + +// seriesPageSize is the row read's page length. The tie-break in the query's +// ORDER BY is what makes this a stable page boundary — see SeriesPage. +const seriesPageSize = 50 + +// AdminSeries is one Series as the owner's library-wide view sees it: a +// Series-level fact plus an anonymous Reader count. ReaderCount being zero is +// the orphan marker. RaisedByReader is the only trace of the Sighting +// mechanism here; the Reader id behind it never reaches this type. +type AdminSeries struct { + Site string + SeriesID string + Title string + SeriesURL string + CoverAddress string // "" = no Cover yet + Kind string + LatestChapter string + LatestChapterNum *float64 // nil until first captured + LatestCheckedAt 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 +// of every Series matching the same filter — a window number, not the page's +// len, so the landing page's figure and the list heading come from one query. +type SeriesPage struct { + Rows []AdminSeries + Total int +} + +// SiteSeriesShape is one Site's share of the Series matching a filter: how +// many, and the manga/novel split. One grouped pass, then library-wide totals +// are summed in Go over the rows — the landing page's per-Site table reads +// this and never pays for the rows the list discards. +type SiteSeriesShape struct { + Site string + Total int + Manga int + Novel int +} + +// Key returns the canonical identity in bookmark-key form (":"). +func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID } + +// adminFilter maps a filter's named predicate to its compile-time WHERE and +// HAVING clauses and their bound parameters — the name never reaches query +// text, and Site and Kind bind as parameters. Shared by the row read and the +// per-Site aggregate so the two cannot disagree on what a filter means. +// +// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch- +// gate case is invisible to SQL, needs the Site registry in Go, and belongs to +// a later repair), never-read-a-chapter and never-checked as disjoint halves +// (non-zero versus zero check stamp), stale, no cover, and Reader-report. +// no_readers is the one HAVING predicate: it is the orphan test, an aggregate +// over the LEFT JOIN, where a bare WHERE has no row to test. +// +// stale is the checked-but-old half of the stamp partition — because the +// verdict line wants "not checked in twelve hours" as one figure, and a never +// checked Series is already counted on its own "waiting"/never-checked +// filter, folding it in would double-report it. The landing page computes the +// inclusive number as stale + never-checked. +func adminFilter(f SeriesFilter) (where, having string, args []any, err error) { + var clauses []string + if f.Kind != "" { + args = append(args, f.Kind) + clauses = append(clauses, "s.kind = $"+strconv.Itoa(len(args))) + } + switch f.Name { + case "", SeriesFilterAll: + case SeriesFilterNoURL: + clauses = append(clauses, `s.series_url = ''`) + case SeriesFilterNoChapter: + clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`) + case SeriesFilterNeverChecked: + clauses = append(clauses, `s.latest_checked_at = 0`) + case SeriesFilterStale: + clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)) + args = append(args, f.Cutoff) + case SeriesFilterNoCover: + clauses = append(clauses, `s.cover_address = ''`) + case SeriesFilterReaderReport: + clauses = append(clauses, `s.latest_raised_by IS NOT NULL`) + case SeriesFilterNoReaders: + having = `HAVING COUNT(b.reader_id) = 0` + default: + return "", "", nil, fmt.Errorf("unknown series filter %q", f.Name) + } + if len(clauses) > 0 { + where = "WHERE " + strings.Join(clauses, " AND ") + } + return where, having, args, nil +} + +// SeriesPage returns one page of the Series matching the filter, least +// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the +// orphans that hygiene has to find — an inner join would hide them, exactly +// as the Lane's join does. ReaderCount is a plain count of every Bookmark on +// the Series, which knowingly disagrees with the two Lane queries for as long +// as the finished lifecycle bucket exists (#140). +// +// The tie-break is mandatory, not decorative: every unpollable Series shares a +// zero check stamp, so ordering on that column alone gives no stable page +// boundary and rows would repeat or vanish across pages. (site, series_id) is +// the primary key, hence total. The filtered total is a window count in the +// same query — window functions run after grouping and before the limit, so +// one where-clause cannot disagree with a second copy of itself. +func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { + where, having, args, err := adminFilter(f) + if err != nil { + return SeriesPage{}, err + } + if f.Page < 1 { + f.Page = 1 + } + // Site narrowing is the row read's own; the aggregate must see every Site. + if f.Site != "" { + args = append(args, f.Site) + clause := "s.site = $" + strconv.Itoa(len(args)) + if where == "" { + where = "WHERE " + clause + } else { + where += " AND " + clause + } + } + base := len(args) + args = append(args, seriesPageSize, seriesPageSize*(f.Page-1)) + rows, err := s.db.Query(` + SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, + COUNT(b.reader_id) AS reader_count, + COUNT(*) OVER () AS filtered_total + FROM series s + LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id + `+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 + `+having+` + ORDER BY s.latest_checked_at, s.site, s.series_id + LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) + if err != nil { + return SeriesPage{}, fmt.Errorf("query series page: %w", err) + } + defer rows.Close() + + out := SeriesPage{} + for rows.Next() { + a, total, err := scanAdminSeries(rows.Scan) + if err != nil { + return SeriesPage{}, fmt.Errorf("scan series page: %w", err) + } + out.Rows = append(out.Rows, a) + out.Total = total + } + return out, rows.Err() +} + +// SeriesShapes returns each Site's share of the Series matching the filter, +// one grouped pass. Site and Page are row-read concerns and are ignored; the +// Landing page reads this per Site and sums the totals in Go for the +// library-wide figure. +func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) { + where, having, args, err := adminFilter(f) + if err != nil { + return nil, err + } + rows, err := s.db.Query(` + SELECT site, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE kind = 'manga') AS manga, + COUNT(*) FILTER (WHERE kind = 'novel') AS novel + FROM ( + SELECT s.site, s.kind + FROM series s + LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id + `+where+` + GROUP BY s.site, s.series_id, s.kind + `+having+` + ) shape + GROUP BY site + ORDER BY site`, args...) + if err != nil { + return nil, fmt.Errorf("query series shapes: %w", err) + } + defer rows.Close() + + out := []SiteSeriesShape{} + for rows.Next() { + var sh SiteSeriesShape + if err := rows.Scan(&sh.Site, &sh.Total, &sh.Manga, &sh.Novel); err != nil { + return nil, fmt.Errorf("scan series shape: %w", err) + } + out = append(out, sh) + } + return out, rows.Err() +} + +// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +// order, plus the query's reader_count and filtered_total columns, and returns +// the window total alongside the row. latest_chapter_num is NULL until first +// captured — the "never read a chapter" state. The Sighting-raiser column is +// never among the scanned columns. +func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) { + var ( + a AdminSeries + latestChapterNum sql.NullFloat64 + total int + ) + if err := scan( + &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, + &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, + &a.RaisedByReader, &a.ReaderCount, &total, + ); err != nil { + return AdminSeries{}, 0, err + } + if latestChapterNum.Valid { + a.LatestChapterNum = &latestChapterNum.Float64 + } + return a, total, nil +} diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go new file mode 100644 index 0000000..8a62d55 --- /dev/null +++ b/backend/internal/store/admin_test.go @@ -0,0 +1,350 @@ +package store + +import ( + "reflect" + "strconv" + "strings" + "testing" +) + +// seriesSeed describes one Series (and optionally its bookmarks) to stand up +// for an admin filter test. Direct SQL, because the filters separate rows the +// Upsert path could not produce together: an orphan has no bookmark, and a +// Reader-raised Latest Chapter needs a Sighting the store does not create. +type seriesSeed struct { + key string + kind string + url string + cover string // cover_address + checkedAt int64 + latestNum *float64 + bookmarks int // readers that hold it; 0 = orphan + raisedBy bool // a Reader's report is attributed as the raiser +} + +// seedAdminSeries inserts one series row and its bookmarks (owner first, then +// fresh readers), with the exact admin-relevant facts a test needs. +func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) { + t.Helper() + site, seriesID, ok := strings.Cut(seed.key, ":") + if !ok { + t.Fatalf("key %q: no ':' separator", seed.key) + } + if seed.kind == "" { + seed.kind = "manga" + } + var latestChapter any = "" + if seed.latestNum != nil { + latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64) + } + if _, err := s.db.Exec(` + INSERT INTO series (site, series_id, title, kind, series_url, cover_address, + latest_checked_at, latest_chapter, latest_chapter_num) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover, + seed.checkedAt, latestChapter, seed.latestNum); err != nil { + t.Fatalf("seed series %q: %v", seed.key, err) + } + for i := range seed.bookmarks { + var readerID int64 = s.OwnerID() + if i > 0 { + readerID = secondReader(t, s) + } + if _, err := s.db.Exec(` + INSERT INTO bookmarks (reader_id, site, series_id, + last_chapter, last_chapter_num, last_chapter_url, + favorite, status, updated_at) + VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`, + readerID, site, seriesID, seed.checkedAt); err != nil { + t.Fatalf("seed bookmark %q: %v", seed.key, err) + } + } + if seed.raisedBy { + if _, err := s.db.Exec( + `UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`, + s.OwnerID(), site, seriesID); err != nil { + t.Fatalf("seed raised-by %q: %v", seed.key, err) + } + } +} + +func pageKeys(t *testing.T, s *Store, f SeriesFilter) map[string]bool { + t.Helper() + page, err := s.SeriesPage(f) + if err != nil { + t.Fatalf("SeriesPage(%+v): %v", f, err) + } + keys := map[string]bool{} + for _, a := range page.Rows { + keys[a.Key()] = true + } + return keys +} + +// Each filter must return the rows it names and no others, over one shared +// seeded mix where every healthy neighbour is present to be wrongly returned. +// The stale cutoff is 5000: a Series checked at 9000 is current, at 2000 stale. +func TestAdminSeriesFilters(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:nourl", url: "", cover: "bbb", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "https://asurascans.com/comics/nochapter", cover: "ccc", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:neverchecked", url: "https://asurascans.com/comics/neverchecked", cover: "ddd", checkedAt: 0, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:orphan", url: "https://asurascans.com/comics/orphan", cover: "eee", checkedAt: 9000, latestNum: new(7.0), bookmarks: 0}) + seedAdminSeries(t, s, seriesSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "fff", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:report", url: "https://asurascans.com/comics/report", cover: "ggg", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true}) + + cases := []struct { + name string + f SeriesFilter + want []string + }{ + {"all", SeriesFilter{}, []string{"asura:healthy", "asura:nourl", "asura:nochapter", "asura:neverchecked", "asura:orphan", "asura:stale", "asura:nocover", "asura:report"}}, + {"no series url", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:nourl"}}, + {"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}}, + {"never checked", SeriesFilter{Name: SeriesFilterNeverChecked}, []string{"asura:neverchecked"}}, + {"no readers", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:orphan"}}, + {"stale", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, []string{"asura:stale"}}, + {"no cover", SeriesFilter{Name: SeriesFilterNoCover}, []string{"asura:nocover"}}, + {"reader report", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:report"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pageKeys(t, s, tc.f) + want := map[string]bool{} + for _, k := range tc.want { + want[k] = true + } + if len(got) != len(want) { + t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want) + } + for k := range want { + if !got[k] { + t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got) + } + } + }) + } +} + +// "Never read a chapter" and "never checked" are disjoint by construction: +// the first requires a non-zero check stamp, the second a zero one. Over a +// mix that should satisfy both, no row may be counted twice. +func TestAdminNeverChapterAndNeverCheckedAreDisjoint(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:neverchecked", url: "u", checkedAt: 0, bookmarks: 1}) + // A zero-stamp, no-chapter row is never-checked only: if never-read-a- + // chapter ever lost its non-zero-stamp guard, it would claim this row too + // and the two counts would double-report it. + seedAdminSeries(t, s, seriesSeed{key: "asura:both", url: "u", checkedAt: 0, bookmarks: 1}) + noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter}) + neverChecked := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNeverChecked}) + for k := range noChapter { + if neverChecked[k] { + t.Fatalf("row %q matches both never-read-a-chapter and never-checked", k) + } + } + if !noChapter["asura:nochapter"] || !neverChecked["asura:neverchecked"] { + t.Fatalf("disjoint split lost its own rows: no-chapter=%v never-checked=%v", noChapter, neverChecked) + } +} + +// Several rows share a zero check stamp, so ordering on latest_checked_at +// alone gives no stable page boundary. The (site, series_id) tie-break must +// make page 2 a strict continuation of page 1: no repeat, no vanishing row. +func TestAdminSeriesPageTieBreakIsStable(t *testing.T) { + s := newTestStore(t) + const total = 53 // > one page, < two (page size 50) + for i := range total { + id := "tie" + strconv.Itoa(i) + seedAdminSeries(t, s, seriesSeed{key: "asura:" + id, url: "u", checkedAt: 0, bookmarks: 1}) + } + // A second Site's zero-stamp row is part of the same all-filter list, and + // must land on a valid page boundary rather than duplicating or dropping + // one of asura's rows: the tie-break is global (site, series_id). + seedAdminSeries(t, s, seriesSeed{key: "demonic:z", url: "u", checkedAt: 0, bookmarks: 1}) + wantTotal := total + 1 + + p1, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll}) + if err != nil { + t.Fatalf("SeriesPage page 1: %v", err) + } + p2, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll, Page: 2}) + if err != nil { + t.Fatalf("SeriesPage page 2: %v", err) + } + if len(p1.Rows) != seriesPageSize { + t.Fatalf("page 1 has %d rows, want %d", len(p1.Rows), seriesPageSize) + } + seen := map[string]bool{} + for _, a := range append(append([]AdminSeries{}, p1.Rows...), p2.Rows...) { + if seen[a.Key()] { + t.Fatalf("row %q repeats across pages", a.Key()) + } + seen[a.Key()] = true + } + if len(seen) != wantTotal { + t.Fatalf("%d distinct rows across pages, want %d (a row vanished)", len(seen), wantTotal) + } + if p1.Total != wantTotal { + t.Fatalf("page total = %d, want %d (the window count must span pages)", p1.Total, wantTotal) + } + // A page beyond the end is empty, not an error (the list re-reads page 1). + // The window count runs over the rows present in the result set, so an + // overflow page has no rows and therefore no total — the caller must not + // render it, which is exactly why the list re-reads page 1. + pFinal, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll, Page: 99}) + if err != nil { + t.Fatalf("SeriesPage beyond end: %v", err) + } + if len(pFinal.Rows) != 0 { + t.Fatalf("page beyond end = %d rows, want 0", len(pFinal.Rows)) + } +} + +// The filtered total is the window number over the same filter the rows use, +// and the per-Site aggregate sums to the same figure — so the landing page's +// count and the list's heading can never disagree, whoever computes them. +func TestAdminTotalAgreesWithRowsAndShapes(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:a", url: "u", cover: "a", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:b", url: "u", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "asura:c", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: new(2.0), raisedBy: true}) + seedAdminSeries(t, s, seriesSeed{key: "demonic:d", url: "u", kind: "novel", checkedAt: 9000, bookmarks: 1}) + + filters := []SeriesFilter{ + {}, + {Name: SeriesFilterNoCover}, + {Name: SeriesFilterReaderReport}, + {Name: SeriesFilterNoChapter}, + } + for _, f := range filters { + page, err := s.SeriesPage(f) + if err != nil { + t.Fatalf("SeriesPage(%+v): %v", f, err) + } + want := len(page.Rows) + if f.Page == 0 && want == seriesPageSize { + t.Fatalf("seed produced a full page; bump the seed or drop page size in the test") + } + if page.Total != want { + t.Fatalf("%+v total = %d, want %d (window count disagrees with row count)", f, page.Total, want) + } + shapes, err := s.SeriesShapes(f) + if err != nil { + t.Fatalf("SeriesShapes(%+v): %v", f, err) + } + sum := 0 + for _, sh := range shapes { + sum += sh.Total + } + if sum != want { + t.Fatalf("%+v aggregate sum = %d, want %d (aggregate disagrees with row query)", f, sum, want) + } + } + + // The default filter's aggregate carries the library shape: per-Site + // totals and the manga/novel split, summed in Go for library wide. + shapes, err := s.SeriesShapes(SeriesFilter{}) + if err != nil { + t.Fatalf("SeriesShapes default: %v", err) + } + if len(shapes) != 2 || shapes[0].Site != "asura" || shapes[1].Site != "demonic" { + t.Fatalf("shapes = %+v, want asura then demonic", shapes) + } + if shapes[0].Total != 3 || shapes[0].Manga != 3 || shapes[0].Novel != 0 { + t.Fatalf("asura shape = %+v, want 3 manga, 0 novel", shapes[0]) + } + if shapes[1].Total != 1 || shapes[1].Manga != 0 || shapes[1].Novel != 1 { + t.Fatalf("demonic shape = %+v, want 1 novel", shapes[1]) + } +} + +// Site and Library narrowing stack on a named filter without changing what +// the filter means. +func TestAdminFilterSiteAndKindNarrow(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:aa", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: new(1.0)}) + seedAdminSeries(t, s, seriesSeed{key: "asura:ab", url: "", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "demonic:aa", url: "u", kind: "novel", checkedAt: 9000, bookmarks: 1}) + seedAdminSeries(t, s, seriesSeed{key: "demonic:ab", url: "", kind: "novel", checkedAt: 9000, bookmarks: 1}) + + got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoURL, Site: "asura"}) + if len(got) != 1 || !got["asura:ab"] { + t.Fatalf("site+nourl = %v, want only asura:ab", got) + } + got = pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoURL, Kind: "novel"}) + if len(got) != 1 || !got["demonic:ab"] { + t.Fatalf("kind+nourl = %v, want only demonic:ab", got) + } + got = pageKeys(t, s, SeriesFilter{Name: SeriesFilterAll, Site: "demonic", Kind: "novel"}) + if len(got) != 2 || !got["demonic:aa"] || !got["demonic:ab"] { + t.Fatalf("site+kind+all = %v, want both demonic rows", got) + } + + // The aggregate ignores the Site narrowing (it is per-Site by shape), but + // honours the Library narrowing: asura's missing-URL row is manga, so the + // novel no-URL list is demonic alone. + shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterNoURL, Kind: "novel"}) + if err != nil { + t.Fatalf("SeriesShapes: %v", err) + } + if len(shapes) != 1 || shapes[0].Site != "demonic" || + shapes[0].Total != 1 || shapes[0].Novel != 1 { + t.Fatalf("novel no-URL aggregate = %+v, want demonic {Total:1 Novel:1}", shapes) + } +} + +// The projection is the privacy boundary: a Series whose Latest Chapter was +// raised by a Reader's report reads back with the anonymous boolean set, not +// with the Reader's id, and no Reader id travels in any returned row. +func TestAdminSeriesReportsAnonymously(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:raised", url: "u", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1, raisedBy: true}) + seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1}) + + page, err := s.SeriesPage(SeriesFilter{}) + if err != nil { + t.Fatalf("SeriesPage: %v", err) + } + byKey := map[string]AdminSeries{} + for _, a := range page.Rows { + byKey[a.Key()] = a + } + if !byKey["asura:raised"].RaisedByReader { + t.Fatal("Reader-raised Series read back RaisedByReader=false") + } + if byKey["asura:polled"].RaisedByReader { + t.Fatal("Poll-raised Series read back RaisedByReader=true") + } +} + +// The privacy test that cannot rot into a template-only guarantee: assert the +// admin column constant does not carry the Sighting-raiser column and that the +// admin row type has no field for it, modelled on the guard on the Bookmark +// column list. +func TestAdminProjectionHidesSightingRaiser(t *testing.T) { + if strings.Contains(adminSeriesColumns, "latest_raised_by") { + t.Fatal("admin column list carries latest_raised_by: the Sighting-raiser id would reach the owner") + } + if _, ok := reflect.TypeOf(AdminSeries{}).FieldByName("LatestRaisedBy"); ok { + t.Fatal("AdminSeries carries a field for the Sighting-raiser id") + } +} + +// An unknown filter name is rejected rather than silently meaning "all" — +// otherwise a mistyped URL would present an empty page as the whole library. +func TestAdminFilterUnknownNameRejected(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 1}) + for name, call := range map[string]func() error{ + "page": func() error { _, err := s.SeriesPage(SeriesFilter{Name: "bogus"}); return err }, + "shape": func() error { _, err := s.SeriesShapes(SeriesFilter{Name: "bogus"}); return err }, + } { + if err := call(); err == nil || !strings.Contains(err.Error(), "unknown series filter") { + t.Fatalf("%s with bogus filter = %v, want unknown-filter error", name, err) + } + } +} diff --git a/backend/internal/store/migrations/0014_admin_read_model.sql b/backend/internal/store/migrations/0014_admin_read_model.sql new file mode 100644 index 0000000..af059e5 --- /dev/null +++ b/backend/internal/store/migrations/0014_admin_read_model.sql @@ -0,0 +1,11 @@ +-- Admin read-model foundation (#140). The Series list's default order is +-- least-recently-checked first, so the table — which has only its primary key +-- today — gets an index that can serve it. A grouped query over a join may +-- ignore the index, so this is a judgement, not a measurement: re-time on real +-- data before adding a second. +CREATE INDEX series_latest_checked_at_idx ON series (latest_checked_at); + +-- force_poll_at is the "ask for one Series to be checked now" stamp (#146). +-- Zero means never forced; nothing reads the column before that ticket wires +-- it, so it lands here unused. +ALTER TABLE series ADD COLUMN force_poll_at bigint NOT NULL DEFAULT 0; -- 2.52.0 From 503fb49d0a15873e8e01e22bd0826c5c15f5e81e Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 18:48:30 +0700 Subject: [PATCH 04/16] feat(latest): record one poll pass per exit with skip reason and outcome counts Every way a Lane pass can end now writes exactly one durable row: a skip value naming the exit (paused, refusing, sidecar-down, no-fetcher, due-query, asleep, eligible-count, nothing-eligible, or empty for the loop), five outcome counts from the classification the Series read already makes, and carry-forward of the previous pass's figures exactly when the pass's own gap is zero. Refusal is durable through the poll_lanes row, so a restart does not re-probe a Site inside its backoff. Retention is 14 days. The mid-loop browser-unreachable return writes an empty skip by design: a tenth value is not invented here. (#141) --- backend/internal/latest/poller.go | 224 ++++++++++-- backend/internal/latest/poller_test.go | 460 ++++++++++++++++++++++++- backend/internal/store/store.go | 18 +- backend/internal/store/store_test.go | 27 +- 4 files changed, 686 insertions(+), 43 deletions(-) diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index 50741a8..dac582b 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -48,6 +48,12 @@ type Poller struct { // same failure-isolated prefetch path. CoverBytesFetch CoverBytesFetcher Now func() time.Time // injected so tests can freeze it + // eligibleCount reports how many of a Site's Series are eligible for + // polling, defaulting to Store.EligibleSeriesCount. Injected so tests can + // fail the count alone: the eligible query shares the due query's tables, + // so no real store failure can reach this path without breaking the due + // query first (issue #141). + eligibleCount func(site string) (int, error) // refuseUntil gates a Site's Lane after it refused twice in one run: no // Series of that Site is attempted again before this time (issue #100). @@ -216,6 +222,74 @@ func (p *Poller) runOnce(ctx context.Context) { } } +// One skip value per way a Lane Pass can return before its loop (issue #141); +// empty means the pass reached the loop. The values are wire strings — stored +// in poll_passes and read by the Lanes page — so they are stable, not prose. +const ( + skipPaused = "paused" // the pause row was read at the top + skipRefusing = "refusing" // refusal backoff + skipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome + skipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback + skipDueQuery = "due-query" // the due query failed + skipAsleep = "asleep" // under both browser wake thresholds + skipEligibleCount = "eligible-count" // the eligible count failed + skipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest +) + +// readOutcome classifies one Series read for the pass row's outcome counts +// (issue #141). The classification the read already makes is counted, never a +// second taxonomy: refused is the Site holding a challenge, unreachable the +// browser interrupting, noChapter a 200 with real HTML but no chapter links, +// unfetchable the host pin or a missing fetcher, and errors everything else. +type readOutcome int + +const ( + outcomeSuccess readOutcome = iota + outcomeRefused + outcomeUnreachable + outcomeNoChapter + outcomeUnfetchable + outcomeError +) + +// outcomeCounts are the five named outcome counts of one pass. A success +// count is derived, never stored: checked minus the four, with unreachable +// excluded because the sidecar-loss path returns before the checked counter +// increments (issue #141). +type outcomeCounts struct { + refused, unreachable, noChapter, unfetchable, errors int +} + +func (c *outcomeCounts) add(o readOutcome) { + switch o { + case outcomeRefused: + c.refused++ + case outcomeUnreachable: + c.unreachable++ + case outcomeNoChapter: + c.noChapter++ + case outcomeUnfetchable: + c.unfetchable++ + case outcomeError: + c.errors++ + } +} + +// passRecord is what one pass's durable row will be: the skip value and +// outcome counts filled in along the pass's return path. recordPass assembles +// the row, so every exit records exactly once. +type passRecord struct { + site string + ranAt int64 + skip string + counts outcomeCounts +} + +// lanePassRetention is how far back a Lane's pass log is kept. It is not the +// display window: retention is how far back a question can reach, and the +// window is what the owner is shown (issue #139). +const lanePassRetention = 14 * 24 * time.Hour + // runLanePass processes one pass of one Site's Lane: select the due Series, // pace through them, and report how long the Lane should wait before its next // pass. paced spaces consecutive fetches by the Site's effective gap — the @@ -226,9 +300,32 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // return path, with the figures filled in where the pass computes them. st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)} defer func() { p.recordLaneState(st) }() - if until := p.refusalBackoff(name); now.Before(until) { + // Durable pass log (issue #141): one row per exit, written from the same + // snapshot so the two recordings cannot disagree. + rec := passRecord{site: name, ranAt: now.UnixMilli()} + defer func() { p.recordPass(rec, st) }() + + // One Lane row read at the top of a pass, serving two gates (issue #139). + // Both stamps outlive our process, so the gates read the durable row + // rather than memory: a refusal is the Site's mood and a pause the + // owner's order, and neither is lost to a restart. + pausedUntil, refuseUntil, err := p.Store.LaneGates(name) + if err != nil { + // Fail open: a store that cannot answer the gate cannot record the + // pass either, and one Lane must not stall on its own gate read. + log.Printf("latest poll %s: lane gates: %v", name, err) + } + if pausedUntil > now.UnixMilli() { + // Paused ahead of the refusal check: no Series is touched, so the + // queue stays intact for when the pause lifts (issue #141, #147). + rec.skip = skipPaused + log.Printf("latest poll %s: paused until %s, skipping pass", name, time.UnixMilli(pausedUntil).Format(time.RFC3339)) + return time.Duration(pausedUntil-now.UnixMilli()) * time.Millisecond + } + if refuseUntil > now.UnixMilli() { // Cooling down after a refusal: do not attempt this Site at all. - return until.Sub(now) + rec.skip = skipRefusing + return time.Duration(refuseUntil-now.UnixMilli()) * time.Millisecond } if isBrowserSite(name) { if downFor, down := p.browserDownFor(now); down && downFor < refuseBackoff { @@ -236,6 +333,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // window: skip this pass, so a restarting Chrome does not stamp // this Site's Series one pass at a time. After refuseBackoff the // flag decays and the Lane probes again (issue #100, story 20). + rec.skip = skipSidecarDown log.Printf("latest poll %s: browser lane skipping pass (sidecar down %s ago)", name, downFor) return refuseBackoff - downFor } @@ -246,6 +344,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // No fetcher at all right now (browser absent, no fallback): every // Series stays unstamped and due, so a browser that appears after a // restart finds its full queue waiting (issue #100). + rec.skip = skipNoFetcher st.Gap = defaultGap return defaultGap } @@ -253,6 +352,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(), now.Add(-sightingCeilingRests*s.Rest).UnixMilli()) if err != nil { + rec.skip = skipDueQuery log.Printf("latest poll %s: due query: %v", name, err) st.Gap = defaultGap return defaultGap @@ -263,6 +363,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // 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. + rec.skip = skipAsleep st.Gap, st.Asleep = defaultGap, true return defaultGap } @@ -276,8 +377,9 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. } } - eligible, err := p.Store.EligibleSeriesCount(name) + eligible, err := p.countEligible(name) if err != nil { + rec.skip = skipEligibleCount log.Printf("latest poll %s: eligible count: %v", name, err) st.Gap = defaultGap return defaultGap @@ -290,6 +392,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. if eligible == 0 { // Nothing to poll for the foreseeable future; sleep a full rest instead // of re-querying every gap. + rec.skip = skipNothingEligible return s.Rest } @@ -314,20 +417,24 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. break } } - if err := p.checkOne(ctx, sr); err != nil { - switch { - case errors.Is(err, errChallengeHeld): - refusals++ - case errors.Is(err, errBrowserInterrupted): - p.setBrowserDown(now) - log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, refuseBackoff) - return gap - default: - refusals = 0 - } + outcome := p.checkOne(ctx, sr) + if outcome == outcomeUnreachable { + // The mid-loop browser loss writes an empty skip on purpose: the + // pass returns before the checked counter increments, so its row + // is stall-shaped (due > 0, checked 0, skip ''), and a stall is + // the exact signal this exit produces. A tenth skip value would + // make it legible but is deliberately not invented here. + rec.counts.add(outcome) + p.setBrowserDown(now) + log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, refuseBackoff) + return gap + } + if outcome == outcomeRefused { + refusals++ } else { refusals = 0 } + rec.counts.add(outcome) st.Checked++ } if st.Checked > 0 { @@ -335,16 +442,65 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. } if refusals >= 2 { p.setRefusalBackoff(name, now.Add(refuseBackoff)) + // The refusal outlives the process: the durable stamp gates a restart, + // so a Site that just told us to back off is not re-probed. The + // in-memory twin is still written because the Lane status block reads + // it directly; the gate reads the durable stamp, so a restart does not + // forget the refusal. + if err := p.Store.SetLaneRefusal(name, now.Add(refuseBackoff).UnixMilli()); err != nil { + log.Printf("latest poll %s: persist refusal: %v", name, err) + } log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff) return refuseBackoff } return gap } -func (p *Poller) refusalBackoff(name string) time.Time { - p.mu.Lock() - defer p.mu.Unlock() - return p.refuseUntil[name] +// recordPass writes the durable row for one pass (issue #141). Called deferred +// from runLanePass so every return path records exactly one row. A pass that +// never computed its own figures — its gap is zero — carries the previous +// pass's due, gap, clamped and checked forward rather than stating zeroes it +// did not measure; the skip column says why it declined, so the zeroes that +// remain (due-query, no-fetcher) read as explanations rather than +// measurements. +func (p *Poller) recordPass(rec passRecord, st LaneState) { + row := store.LanePass{ + Site: rec.site, + RanAt: rec.ranAt, + Skip: rec.skip, + Due: st.Due, + Checked: st.Checked, + GapMS: st.Gap.Milliseconds(), + Clamped: st.Clamped, + Refused: rec.counts.refused, + Unreachable: rec.counts.unreachable, + NoChapter: rec.counts.noChapter, + Unfetchable: rec.counts.unfetchable, + Errors: rec.counts.errors, + } + if row.GapMS == 0 { + // The pass never computed a gap, so it has no figures of its own: + // carry the previous pass's, in one latest-per-Site read — the + // recorder needs one Site, not six (issue #139). + if prev, ok, err := p.Store.LatestLanePass(rec.site); err != nil { + log.Printf("latest poll %s: previous pass: %v", rec.site, err) + } else if ok { + row.Due, row.Checked = prev.Due, prev.Checked + row.GapMS, row.Clamped = prev.GapMS, prev.Clamped + } + } + if err := p.Store.RecordLanePass(row, rec.ranAt-lanePassRetention.Milliseconds()); err != nil { + log.Printf("latest poll %s: record lane pass: %v", rec.site, err) + } +} + +// countEligible routes the eligible count through the test seam when one is +// set, else the store. +func (p *Poller) countEligible(site string) (int, error) { + if p.eligibleCount != nil { + return p.eligibleCount(site) + } + return p.Store.EligibleSeriesCount(site) } func (p *Poller) setRefusalBackoff(name string, until time.Time) { @@ -409,13 +565,15 @@ func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.D // checkOne re-checks one series. Every failure path here is "log and move on": // the poller is a best-effort enhancement, and no single bad series may stall a -// Lane or take down the process. The returned error is the page read's -// classified outcome so the Lane can tell a refusal from a loss of the -// browser; non-classified failures still return nil-equivalent behaviour. -func (p *Poller) checkOne(ctx context.Context, sr store.Series) error { +// Lane or take down the process. The returned outcome classifies the read for +// the pass row (issue #141), so the Lane can count a refusal, a lost browser, +// a chapter-less page, an unfetchable address or a transport error without +// re-deriving the taxonomy. +func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOutcome) { defer func() { if r := recover(); r != nil { log.Printf("latest poll %q: recovered from panic: %v", sr.Key(), r) + outcome = outcomeError } }() @@ -427,7 +585,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error { // the stamp means "attempted", and an untried Series stays due. if err := p.Store.MarkLatestChecked(sr.Site, sr.SeriesID, p.Now().UnixMilli()); err != nil { log.Printf("latest poll %q: mark checked: %v", sr.Key(), err) - return nil + return outcomeError } facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, p.BrowserFetch, p.Fetch) @@ -438,17 +596,23 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error { // passes the gate is retried at rest pace rather than // hot-looping. log.Printf("latest poll %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL) - return err + return outcomeUnfetchable case errors.Is(err, errNoFetcher): log.Printf("latest poll %q: no fetcher for site %q", sr.Key(), sr.Site) - return err + return outcomeUnfetchable } // A legacy cover heals independently of the page read: its source may // answer — a CDN — while the origin does not, so a fetch failure does // not skip the heal, matching the order the shared read replaced. p.healCover(ctx, sr) log.Printf("latest poll %q: %v", sr.Key(), err) - return err + if errors.Is(err, errChallengeHeld) { + return outcomeRefused + } + if errors.Is(err, errBrowserInterrupted) { + return outcomeUnreachable + } + return outcomeError } // A legacy cover source is healed independently of the page read. p.healCover(ctx, sr) @@ -459,7 +623,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error { // Most likely a challenge page or a layout change. Either way the row is // already stamped, so this waits out a rest instead of hot-looping. log.Printf("latest poll %q: no chapter links in %d bytes", sr.Key(), facts.BodyLen) - return nil + return outcomeNoChapter } // The Poll is the oracle for whatever Sighting last raised this Series @@ -472,7 +636,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error { // against the due-query snapshot; a concurrent write in between only costs // one redundant UPDATE of the same absolute value, never a wrong one. if sr.LatestChapterNum != nil && *sr.LatestChapterNum == facts.Latest.Num { - return nil + return outcomeSuccess } // Series-level write: the row is shared, so one update refreshes every @@ -481,10 +645,10 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error { // the list. if err := p.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil { log.Printf("latest poll %q: set latest chapter: %v", sr.Key(), err) - return nil + return outcomeError } log.Printf("latest poll %q: latest is now %s", sr.Key(), facts.Latest.Label) - return nil + return outcomeSuccess } // judgeSighting settles the Sighting the Series' stored Latest Chapter is owed diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index 72736bb..0ae089e 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -427,9 +427,12 @@ func TestRunLogsLaneDefaults(t *testing.T) { log.SetOutput(&logs) t.Cleanup(func() { log.SetOutput(previous) }) + // The pass log writes through the store, so Run needs a real one — a + // poller without a Store is not a poller. + s, _ := newTestStore(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - (&Poller{Now: func() time.Time { return time.Now() }}).Run(ctx) + (&Poller{Store: s, Now: func() time.Time { return time.Now() }}).Run(ctx) got := logs.String() for _, want := range []string{"6 lanes", "rest=1h0m0s", "gap=10s"} { @@ -1689,3 +1692,458 @@ func TestLaneStatus(t *testing.T) { t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable) } } + +// latestPassFor reads a Site's newest durable pass row, failing rather than +// returning a zero LanePass a caller would assert against by accident. +func latestPassFor(t *testing.T, s *store.Store, site string) store.LanePass { + t.Helper() + pass, ok, err := s.LatestLanePass(site) + if err != nil || !ok { + t.Fatalf("LatestLanePass(%s): ok=%v err=%v", site, ok, err) + } + return pass +} + +// countPassRows counts a Site's durable pass rows, for asserting that a pass +// records exactly one. +func countPassRows(t *testing.T, dbURL, site string) int { + t.Helper() + db, err := sql.Open("pgx", dbURL) + if err != nil { + t.Fatalf("open %s: %v", dbURL, err) + } + defer db.Close() + var n int + if err := db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, site).Scan(&n); err != nil { + t.Fatalf("count passes for %s: %v", site, err) + } + return n +} + +// One durable pass row per exit, with the skip value naming the exit. The +// mid-loop browser-unreachable return also writes one row — but with an empty +// skip, so the row is stall-shaped (due > 0, checked 0, skip ”), matching the +// deliberate absence of a tenth skip value. +func TestRunLanePassRecordsEveryExit(t *testing.T) { + now := time.UnixMilli(5_000_000) + + t.Run("paused", func(t *testing.T) { + s, dbURL := newTestStore(t) + seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0) + if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil { + t.Fatalf("PauseLane: %v", err) + } + f := &fakeFetcher{body: asuraSeriesFixture, status: 200} + p := newTestPoller(t, s, f, now) + if pace := p.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute { + t.Fatalf("paused pace = %s, want 30m (sleep until the expiry)", pace) + } + if f.callCount() != 0 { + t.Fatalf("fetches while paused = %d, want 0", f.callCount()) + } + if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 { + t.Fatalf("stamp while paused = %d, want 0 (Series stay due and unstamped)", got) + } + if pass := latestPassFor(t, s, "asura"); pass.Skip != skipPaused { + t.Fatalf("skip = %q, want %q", pass.Skip, skipPaused) + } + if got := countPassRows(t, dbURL, "asura"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("refusing", func(t *testing.T) { + s, dbURL := newTestStore(t) + seedForCheck(t, s, "kagane:x", "https://kagane.to/series/x", 0) + if err := s.SetLaneRefusal("kagane", now.Add(10*time.Minute).UnixMilli()); err != nil { + t.Fatalf("SetLaneRefusal: %v", err) + } + browser := &fakeFetcher{status: 200} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = browser + if pace := p.runLanePass(context.Background(), "kagane", false); pace != 10*time.Minute { + t.Fatalf("refusing pace = %s, want 10m", pace) + } + if browser.callCount() != 0 { + t.Fatalf("fetches while refusing = %d, want 0", browser.callCount()) + } + if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing { + t.Fatalf("skip = %q, want %q", pass.Skip, skipRefusing) + } + if got := countPassRows(t, dbURL, "kagane"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("sidecar-down", func(t *testing.T) { + s, dbURL := newTestStore(t) + seedForCheck(t, s, "kagane:x", "https://kagane.to/series/x", 0) + browser := &fakeFetcher{body: kaganeAPIFixture, status: 200} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = browser + p.setBrowserDown(now) // a sibling Lane lost Chrome within the backoff window + if pace := p.runLanePass(context.Background(), "kagane", false); pace != refuseBackoff { + t.Fatalf("sidecar-down pace = %s, want %s", pace, refuseBackoff) + } + if browser.callCount() != 0 { + t.Fatalf("fetches with the sidecar down = %d, want 0", browser.callCount()) + } + if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipSidecarDown { + t.Fatalf("skip = %q, want %q", pass.Skip, skipSidecarDown) + } + if got := countPassRows(t, dbURL, "kagane"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("no-fetcher", func(t *testing.T) { + s, dbURL := newTestStore(t) + seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0) + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + if pace := p.runLanePass(context.Background(), "comix", false); pace != defaultGap { + t.Fatalf("no-fetcher pace = %s, want %s", pace, defaultGap) + } + pass := latestPassFor(t, s, "comix") + if pass.Skip != skipNoFetcher || pass.GapMS != defaultGap.Milliseconds() { + t.Fatalf("no-fetcher pass = %+v, want skip %q with its own gap %s", pass, skipNoFetcher, defaultGap) + } + if got := countPassRows(t, dbURL, "comix"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("due-query", func(t *testing.T) { + s, dbURL := newTestStore(t) + seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0) + // The due query is the pass's first store read after the gates; making + // it fail without touching poll_passes takes its tables away. + db, err := sql.Open("pgx", dbURL) + if err != nil { + t.Fatalf("open %s: %v", dbURL, err) + } + if _, err := db.Exec(`DROP TABLE bookmarks`); err != nil { + t.Fatalf("drop bookmarks: %v", err) + } + db.Close() + p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now) + if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap { + t.Fatalf("due-query pace = %s, want %s", pace, defaultGap) + } + if pass := latestPassFor(t, s, "asura"); pass.Skip != skipDueQuery { + t.Fatalf("skip = %q, want %q", pass.Skip, skipDueQuery) + } + if got := countPassRows(t, dbURL, "asura"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("asleep", func(t *testing.T) { + s, dbURL := newTestStore(t) + for i := 0; i < 3; i++ { + key := fmt.Sprintf("kagane:w%d", i) + seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], now.Add(-62*time.Minute).UnixMilli()) + } + browser := &fakeFetcher{body: kaganeAPIFixture, status: 200} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = browser + if pace := p.runLanePass(context.Background(), "kagane", false); pace != defaultGap { + t.Fatalf("asleep pace = %s, want %s", pace, defaultGap) + } + if browser.callCount() != 0 { + t.Fatalf("fetches while Chrome is asleep = %d, want 0", browser.callCount()) + } + pass := latestPassFor(t, s, "kagane") + if pass.Skip != skipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() { + t.Fatalf("asleep pass = %+v, want skip %q with 3 due and the default gap", pass, skipAsleep) + } + if got := countPassRows(t, dbURL, "kagane"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("eligible-count", func(t *testing.T) { + s, dbURL := newTestStore(t) + // The eligible query shares the due query's tables, so no real store + // failure reaches it after a successful due read; the seam is how the + // path is driven at all (see Poller.eligibleCount). + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.eligibleCount = func(string) (int, error) { return 0, errors.New("count failed") } + if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap { + t.Fatalf("eligible-count pace = %s, want %s", pace, defaultGap) + } + if pass := latestPassFor(t, s, "asura"); pass.Skip != skipEligibleCount { + t.Fatalf("skip = %q, want %q", pass.Skip, skipEligibleCount) + } + if got := countPassRows(t, dbURL, "asura"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("nothing-eligible", func(t *testing.T) { + s, dbURL := newTestStore(t) + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + if pace := p.runLanePass(context.Background(), "asura", false); pace != sites["asura"].Rest { + t.Fatalf("nothing-eligible pace = %s, want a full rest %s", pace, sites["asura"].Rest) + } + if pass := latestPassFor(t, s, "asura"); pass.Skip != skipNothingEligible { + t.Fatalf("skip = %q, want %q", pass.Skip, skipNothingEligible) + } + if got := countPassRows(t, dbURL, "asura"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("reached the loop", func(t *testing.T) { + s, dbURL := newTestStore(t) + const url = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" + seedForCheck(t, s, "asura:chronicles-of-the-demon-faction-f886a8af", url, 0) + p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now) + if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap { + t.Fatalf("loop pace = %s, want %s", pace, defaultGap) + } + pass := latestPassFor(t, s, "asura") + if pass.Skip != "" || pass.Due != 1 || pass.Checked != 1 { + t.Fatalf("loop pass = %+v, want an empty skip with 1 due and 1 checked", pass) + } + if got := countPassRows(t, dbURL, "asura"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) + + t.Run("browser-unreachable mid-loop", func(t *testing.T) { + s, dbURL := newTestStore(t) + seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0) + interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart")) + browser := &fakeFetcher{status: 200, err: interrupted} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = browser + if pace := p.runLanePass(context.Background(), "comix", false); pace != defaultGap { + t.Fatalf("mid-loop pace = %s, want %s", pace, defaultGap) + } + // One row was still written, and it is stall-shaped by construction: + // empty skip with due > 0 and checked 0 because the return precedes + // the checked counter. The assertion below pins that shape precisely. + pass := latestPassFor(t, s, "comix") + if pass.Skip != "" || pass.Unreachable != 1 || pass.Checked != 0 || pass.Due != 1 { + t.Fatalf("mid-loop pass = %+v, want empty skip, unreachable 1, checked 0, due 1", pass) + } + if got := countPassRows(t, dbURL, "comix"); got != 1 { + t.Fatalf("pass rows = %d, want exactly 1", got) + } + }) +} + +// Carry-forward moves verbatim from the in-memory snapshot (issue #141): a +// pass that never computed its own gap carries the previous pass's due, gap, +// clamped and checked forward; a pass with a gap of its own records its own +// figures, zeroes included, beside the skip reason that explains them. +func TestRunLanePassCarryForwardOnlyWhenGapZero(t *testing.T) { + now := time.UnixMilli(5_000_000) + + t.Run("no gap of its own carries the previous pass's figures", func(t *testing.T) { + s, _ := newTestStore(t) + seedForCheck(t, s, "kagane:x", "https://kagane.to/series/x", 0) + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = &fakeFetcher{body: kaganeAPIFixture, status: 200} + p.runLanePass(context.Background(), "kagane", false) + first := latestPassFor(t, s, "kagane") + if first.Due != 1 || first.Checked != 1 || first.GapMS == 0 { + t.Fatalf("first pass = %+v, want a measured pass", first) + } + + if err := s.SetLaneRefusal("kagane", now.Add(10*time.Minute).UnixMilli()); err != nil { + t.Fatalf("SetLaneRefusal: %v", err) + } + // The pass row is keyed (site, ran_at), so the second pass needs its + // own timestamp: a minute later is still inside the refusal. + p.Now = func() time.Time { return now.Add(time.Minute) } + p.runLanePass(context.Background(), "kagane", false) + second := latestPassFor(t, s, "kagane") + if second.Skip != skipRefusing { + t.Fatalf("second pass skip = %q, want %q", second.Skip, skipRefusing) + } + if second.Due != first.Due || second.Checked != first.Checked || + second.GapMS != first.GapMS || second.Clamped != first.Clamped { + t.Fatalf("carried pass = %+v, want the first pass's figures %+v", second, first) + } + }) + + t.Run("a gap of its own records its own figures", func(t *testing.T) { + s, _ := newTestStore(t) + seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0) + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200} + p.runLanePass(context.Background(), "comix", false) + first := latestPassFor(t, s, "comix") + if first.Due != 1 || first.GapMS == 0 { + t.Fatalf("first pass = %+v, want a measured pass", first) + } + + // The no-fetcher exit sets its own gap, so no carry-forward: the due + // count it never gathered records as zero beside its skip reason. A + // minute later gives the second pass its own (site, ran_at) key. + p.BrowserFetch = nil + p.Now = func() time.Time { return now.Add(time.Minute) } + p.runLanePass(context.Background(), "comix", false) + second := latestPassFor(t, s, "comix") + if second.Skip != skipNoFetcher { + t.Fatalf("second pass skip = %q, want %q", second.Skip, skipNoFetcher) + } + if second.GapMS != defaultGap.Milliseconds() { + t.Fatalf("second pass gap = %d, want its own %d (not carried)", second.GapMS, defaultGap.Milliseconds()) + } + if second.Due != 0 || second.Checked != 0 { + t.Fatalf("second pass = %+v, want due 0 checked 0 (its own, not the previous pass's)", second) + } + }) +} + +// The pass row's five outcome counts are the classification the Series read +// already makes — never a second taxonomy (issue #141). Success is derived, +// never stored: checked minus the four named counts, unreachable excluded +// because its exit returns before the checked counter increments. +func TestRunLanePassCountsOutcomes(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(5_000_000) + const ( + successKey = "asura:chronicles-of-the-demon-faction-f886a8af" + successURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" + ) + seeds := map[string]string{ + "asura:refused": "https://asurascans.com/series/refused", + "asura:no-chapter": "https://asurascans.com/comics/no-chapter", + "asura:unfetchable": "https://evil.example/x", + "asura:transport": "https://asurascans.com/series/transport", + successKey: successURL, + } + for key, url := range seeds { + seedForCheck(t, s, key, url, 0) + } + + f := &fakeFetcher{perURL: map[string]fakeResponse{ + seeds["asura:refused"]: {status: 403}, + seeds["asura:no-chapter"]: {body: "", status: 200}, + seeds["asura:transport"]: {err: errors.New("dial tcp: refused")}, + successURL: {body: asuraSeriesFixture, status: 200}, + }} + newTestPoller(t, s, f, now).runLanePass(context.Background(), "asura", false) + + pass := latestPassFor(t, s, "asura") + if pass.Skip != "" || pass.Due != 5 || pass.Checked != 5 { + t.Fatalf("pass = %+v, want a full pass over 5 due Series", pass) + } + if pass.Refused != 1 || pass.Unreachable != 0 || pass.NoChapter != 1 || + pass.Unfetchable != 1 || pass.Errors != 1 { + t.Fatalf("outcome counts = refused %d unreachable %d no_chapter %d unfetchable %d errors %d, want 1 0 1 1 1", + pass.Refused, pass.Unreachable, pass.NoChapter, pass.Unfetchable, pass.Errors) + } + if success := pass.Checked - (pass.Refused + pass.NoChapter + pass.Unfetchable + pass.Errors); success != 1 { + t.Fatalf("derived success = %d, want 1", success) + } + // The one genuine read went through: the success Series carries the + // fixture's newest chapter, and none of the four failures do. + b, ok, err := s.Get(s.OwnerID(), successKey) + if err != nil || !ok { + t.Fatalf("Get: %v ok=%v", err, ok) + } + if b.LatestChapterNum == nil || *b.LatestChapterNum != 181 { + t.Fatalf("success Series latest = %v, want 181 (the fixture's newest)", b.LatestChapterNum) + } + for _, key := range []string{"asura:refused", "asura:no-chapter", "asura:unfetchable", "asura:transport"} { + b, _, err := s.Get(s.OwnerID(), key) + if err != nil { + t.Fatalf("Get %s: %v", key, err) + } + if b.LatestChapterNum != nil { + t.Fatalf("%s latest = %v, want nil (no chapter survived a failed read)", key, *b.LatestChapterNum) + } + } +} + +// A refusal is the Site's mood and outlives our process: the durable stamp a +// pass writes is honoured by a freshly constructed poller, which must not +// re-probe the Site inside its backoff (issue #141). +func TestDurableRefusalSurvivesFreshPoller(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(5_000_000) + // Four due Series: the Lane refuses twice, stamps those two, and leaves + // the remaining two untried and due — the queue the fresh poller must + // find intact once the durable backoff lifts. + for i := 0; i < 4; i++ { + key := fmt.Sprintf("kagane:s%d", i) + seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0) + } + + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.BrowserFetch = &fakeFetcher{status: 403} + p.runLanePass(context.Background(), "kagane", false) + if got := p.BrowserFetch.(*fakeFetcher).callCount(); got != 2 { + t.Fatalf("fetches on the refusing pass = %d, want 2 (refused twice)", got) + } + + // A restart: a brand-new poller, no in-memory refusal, same store. The + // durable stamp gates the pass. + fresh := newTestPoller(t, s, &fakeFetcher{status: 200}, now.Add(14*time.Minute)) + fresh.BrowserFetch = &fakeFetcher{status: 403} + fresh.runLanePass(context.Background(), "kagane", false) + if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 0 { + t.Fatalf("fetches by a fresh poller inside the backoff = %d, want 0", got) + } + if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing { + t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, skipRefusing) + } + + // Past the backoff the fresh poller probes again — the two Series the + // original pass never reached, still due with their stamps untouched. + fresh.Now = func() time.Time { return now.Add(16 * time.Minute) } + fresh.runLanePass(context.Background(), "kagane", false) + if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 2 { + t.Fatalf("fetches after the backoff = %d, want 2", got) + } + for i := 2; i < 4; i++ { + if got := readLatestCheckedAt(t, s, fmt.Sprintf("kagane:s%d", i)); got == 0 { + t.Fatalf("kagane:s%d still untried after the backoff", i) + } + } +} + +// RecordLanePass prunes in the same call that inserts, so the retention +// cutoff the recorder passes is observable in what survives: a row just inside +// 14 days behind the poller's clock is kept, one just outside is pruned +// (issue #139, #141). +func TestPassRetentionCutoffIsFourteenDays(t *testing.T) { + s, dbURL := newTestStore(t) + // A real-world clock: the seeded rows sit 14 days back, so they must be + // positive timestamps or the seed's own prune (ran_at < 0) removes them. + now := time.UnixMilli(1_800_000_000_000) + kept := now.Add(-14*24*time.Hour + time.Minute).UnixMilli() + pruned := now.Add(-14*24*time.Hour - time.Minute).UnixMilli() + for _, ranAt := range []int64{kept, pruned} { + if err := s.RecordLanePass(store.LanePass{Site: "asura", RanAt: ranAt}, 0); err != nil { + t.Fatalf("seed pass at %d: %v", ranAt, err) + } + } + + seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0) + newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now). + runLanePass(context.Background(), "asura", false) + + db, err := sql.Open("pgx", dbURL) + if err != nil { + t.Fatalf("open %s: %v", dbURL, err) + } + defer db.Close() + var n int + if err := db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1 AND ran_at = $2`, "asura", pruned).Scan(&n); err != nil { + t.Fatalf("count pruned row: %v", err) + } + if n != 0 { + t.Fatalf("row at %d survived, want it pruned (older than 14 days)", pruned) + } + if err := db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, "asura").Scan(&n); err != nil { + t.Fatalf("count passes: %v", err) + } + if n != 2 { + t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 869f603..e7ee4b7 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1140,18 +1140,20 @@ func (s *Store) PausedLanes() ([]LanePause, error) { return out, rows.Err() } -// LanePausedUntil reads a Site's pause stamp. A missing state row is the -// default unpaused state. -func (s *Store) LanePausedUntil(site string) (int64, error) { - var until int64 - err := s.db.QueryRow(`SELECT paused_until FROM poll_lanes WHERE site = $1`, site).Scan(&until) +// LaneGates reads a Site's pause and refusal stamps in one row read — the +// top-of-pass gate the poller uses (issue #141). A missing state row is the +// default: unpaused and not refusing. +func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err error) { + err = s.db.QueryRow( + `SELECT paused_until, refuse_until FROM poll_lanes WHERE site = $1`, site). + Scan(&pausedUntil, &refuseUntil) if errors.Is(err, sql.ErrNoRows) { - return 0, nil + return 0, 0, nil } if err != nil { - return 0, fmt.Errorf("lane pause %s: %w", site, err) + return 0, 0, fmt.Errorf("lane gates %s: %w", site, err) } - return until, nil + return pausedUntil, refuseUntil, nil } // DueForLatestCheck returns one Site's series whose server-side diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index fac25e4..a663def 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -1681,6 +1681,24 @@ func TestLanePassOutcomesSumsWindow(t *testing.T) { } } +func TestLaneGatesReadsOneRow(t *testing.T) { + s := newTestStore(t) + paused, refused, err := s.LaneGates("asura") + if err != nil || paused != 0 || refused != 0 { + t.Fatalf("LaneGates on a missing state row = (%d, %d, %v), want (0, 0, nil)", paused, refused, err) + } + if err := s.PauseLane("asura", 2000); err != nil { + t.Fatalf("PauseLane: %v", err) + } + if err := s.SetLaneRefusal("asura", 3000); err != nil { + t.Fatalf("SetLaneRefusal: %v", err) + } + paused, refused, err = s.LaneGates("asura") + if err != nil || paused != 2000 || refused != 3000 { + t.Fatalf("LaneGates = (%d, %d, %v), want (2000, 3000, nil)", paused, refused, err) + } +} + func TestLaneStatePauseResumeAndRefusal(t *testing.T) { s := newTestStore(t) for _, until := range []int64{0, -1} { @@ -1697,8 +1715,9 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) { if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 1}, -1); err != nil { t.Fatalf("RecordLanePass: %v", err) } - if got, err := s.LanePausedUntil("asura"); err != nil || got != 2000 { - t.Fatalf("LanePausedUntil = %d, %v; want 2000", got, err) + pausedUntil, refuseUntil, err := s.LaneGates("asura") + if err != nil || pausedUntil != 2000 || refuseUntil != 3000 { + t.Fatalf("LaneGates = %d/%d, %v; want 2000/3000", pausedUntil, refuseUntil, err) } paused, err := s.PausedLanes() if err != nil { @@ -1715,8 +1734,8 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) { if err != nil || !ok || latest.RefuseUntil != 3000 { t.Fatalf("latest refusal after resume = %+v, ok=%v, err=%v; want 3000 preserved", latest, ok, err) } - if got, err := s.LanePausedUntil("asura"); err != nil || got != 0 { - t.Fatalf("LanePausedUntil after resume = %d, %v; want 0", got, err) + if got, got2, err := s.LaneGates("asura"); err != nil || got != 0 || got2 != 3000 { + t.Fatalf("LaneGates after resume = %d/%d, %v; want 0/3000", got, got2, err) } if paused, err := s.PausedLanes(); err != nil || len(paused) != 0 { t.Fatalf("PausedLanes after resume = %+v, %v; want empty", paused, err) -- 2.52.0 From 14990bde21723dcf5b7242dbdffc698ed2b2ae21 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 19:38:44 +0700 Subject: [PATCH 05/16] feat(web): per-Series detail page keyed by site:series_id (#144) --- backend/internal/web/admin.go | 3 + backend/internal/web/admin_series_detail.go | 90 ++++++++ backend/internal/web/templates/admin.html | 4 +- .../internal/web/templates/series-detail.html | 21 ++ backend/web_test.go | 194 ++++++++++++++++++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 backend/internal/web/admin_series_detail.go create mode 100644 backend/internal/web/templates/series-detail.html diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 3afcaa1..8cd1330 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -27,6 +27,8 @@ type adminView struct { // the Readers they may act on. OwnerID int64 Lanes lanesView + // Detail is the per-Series page data; zero on every other page. + Detail seriesDetailView } // lanesView is the Lane status block: one row per Site that has run, plus the @@ -89,6 +91,7 @@ func (h *Handler) adminRoutes() []adminRoute { {"GET /admin/lanes", h.adminLanes}, {"GET /admin/readers", h.adminReaders}, {"GET /admin/series", h.adminSeries}, + {"GET /admin/series/{key}", h.adminSeriesDetail}, {"GET /ui/admin/lanes", h.uiLanes}, {"POST /readers/{id}/revoke", h.revokeReaderSessions}, {"POST /readers/{id}/clear-marks", h.clearReaderMarks}, diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go new file mode 100644 index 0000000..536333a --- /dev/null +++ b/backend/internal/web/admin_series_detail.go @@ -0,0 +1,90 @@ +package web + +import ( + "log" + "net/http" + "strconv" + "strings" + "time" + + "bookmarkmanager/backend/internal/store" +) + +// seriesDetailView is one Series' page as the owner sees it: strings and +// flags, every judgement made here, the template left to print. ReaderCount +// is the only figure that crosses the privacy boundary — the owner learns how +// many Readers hold the Series, never which Reader reads what. +type seriesDetailView struct { + Key string // ":", the page's address and the Series' identity + Site string + Kind string + // Title, Cover and Chapter come from the shared Series row; the Cover is + // the wire URL of the stored bytes, "" before any exist. + Title string + Cover string + Chapter string // Latest Chapter number, or "—" before the first capture + Checked string // how long ago the poller last checked, or "never" + Readers int + + // Marks, one per hygiene fact, rendered only while it holds. + Unpollable bool // no SeriesURL to fetch + NoCover bool + Orphan bool // no Reader holds the Series + SightingRaised bool // a Reader's Sighting set the Latest Chapter +} + +// adminSeriesDetail renders one Series' page, keyed by the composite +// ":" the list row already shows. The row is read through +// the list's own SeriesPage read narrowed to the key's Site: the admin +// projection is the privacy boundary, and a second single-row read would be a +// second definition of it. +// ponytail: one 50-row page scan per detail view; 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 + } + page, err := h.store.SeriesPage(store.SeriesFilter{Site: site}) + if err != nil { + log.Printf("series detail %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + for i := range page.Rows { + if page.Rows[i].SeriesID == seriesID { + h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(page.Rows[i])}) + return + } + } + http.NotFound(w, r) +} + +// 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 { + v := seriesDetailView{ + Key: a.Key(), + Site: a.Site, + Kind: a.Kind, + Title: a.Title, + Cover: h.store.CoverWireURL(a.CoverAddress), + Readers: a.ReaderCount, + Unpollable: a.SeriesURL == "", + NoCover: a.CoverAddress == "", + Orphan: a.ReaderCount == 0, + SightingRaised: a.RaisedByReader, + } + if a.LatestChapterNum == nil { + v.Chapter = "—" + } else { + v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) + } + if a.LatestCheckedAt == 0 { + v.Checked = "never" + } else { + v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) + } + return v +} diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index 1f60e20..5c7b5b8 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -30,7 +30,7 @@ Overview Lanes Readers - Series + Series
@@ -40,6 +40,8 @@ {{template "readers" .}} {{else if eq .Page "series"}}

Series

+ {{else if eq .Page "series-detail"}} + {{template "series-detail" .Detail}} {{else}}

Overview

{{end}} diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html new file mode 100644 index 0000000..8cbbc80 --- /dev/null +++ b/backend/internal/web/templates/series-detail.html @@ -0,0 +1,21 @@ +{{/* Per-Series page: one address per Series, keyed ":" 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. */}} +{{define "series-detail"}} +← Series +

{{.Title}}

+

{{.Key}} · {{.Site}} · {{.Kind}}

+{{if .Cover}}
+{{else}}
{{end}} +
+ ch {{.Chapter}} + checked {{.Checked}} + {{.Readers}} readers + {{if .Unpollable}}unpollable{{end}} + {{if .NoCover}}no cover{{end}} + {{if .Orphan}}orphan{{end}} + {{if .SightingRaised}}sighting-raised{{end}} +
+
+{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index dcfd21d..492ce77 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -1,6 +1,7 @@ package main import ( + "crypto/sha256" "database/sql" "encoding/json" "fmt" @@ -1840,3 +1841,196 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) { t.Fatal("novel tab=new should render the novel All list") } } + +// The per-Series page renders every Series-level fact the admin read model +// holds for the key the list row already shows: title, the composite key with +// Site and kind, the Latest Chapter, the check age and the anonymous Reader +// count. No Reader identity or progress may appear anywhere in the response. +func TestAdminSeriesDetailRendersFacts(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + // Two Readers hold the same Series, so the anonymous count is 2. + seed(t, st, store.Bookmark{ + Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby", + Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby", + Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45), + }) + other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash"))) + if err != nil { + t.Fatalf("EnsureReader: %v", err) + } + if _, err := st.Upsert(other, store.Bookmark{ + Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby", + Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby", + Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45), + }); err != nil { + t.Fatalf("Upsert second reader: %v", err) + } + if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil { + t.Fatalf("MarkLatestChecked: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{ + "SP Baby", + "kagane:sp-baby · kagane · manga", + "ch 45", + "checked 2h0m0s ago", + "2 readers", + } { + if !strings.Contains(body, want) { + t.Errorf("series detail lacks %q:\n%s", want, body) + } + } +} + +// A Series the poller has never read renders the never-read state — "ch —" and +// "checked never" — rather than a confident zero, and a Series no Reader holds +// renders its count as "0 readers" rather than as a blank. +func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned", + Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned", + Kind: "manga", + }) + // Removing the only Bookmark orphans the Series: the row outlives it. + if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil { + t.Fatalf("Delete: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{"ch —", "checked never", "0 readers"} { + if !strings.Contains(body, want) { + t.Errorf("never-read orphan detail lacks %q:\n%s", want, body) + } + } + if strings.Contains(body, "ch 0") { + t.Errorf("a never-read Series renders chapter 0:\n%s", body) + } +} + +// The meta row renders each hygiene mark exactly when the underlying fact +// holds: a Series with no page to fetch, no cover, no Reader and a chapter a +// Sighting raised carries all four, and a whole one carries none. +func TestAdminSeriesDetailRendersMarks(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + + seed(t, st, store.Bookmark{ + Key: "demonic:broken", Site: "demonic", SeriesID: "broken", + Title: "Broken", Kind: "manga", + }) + if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil { + t.Fatalf("RecordSighting: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} { + if !strings.Contains(rr.Body.String(), want) { + t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String()) + } + } + + seed(t, st, store.Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo", + Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45), + }) + if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp", + []byte("\x00webp-bytes"), "image/webp"); err != nil { + t.Fatalf("SetSeriesCover: %v", err) + } + + req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil) + req.AddCookie(sessionCookie(t, st)) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} { + if strings.Contains(body, mark) { + t.Errorf("whole series detail carries the %q mark:\n%s", mark, body) + } + } + if !strings.Contains(body, `src="https://bookmarks.test/covers/`) { + t.Errorf("whole series detail does not render its stored cover:\n%s", body) + } +} + +// A well-formed key naming no row is a 404, and so is a key with no ":", +// an empty Site or an empty SeriesID — the detail page never answers 500 for +// an address nobody can reach. +func TestAdminSeriesDetailUnknownKey404(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo", + Kind: "manga", + }) + + for _, path := range []string{ + "/admin/series/asura:no-such-row", + "/admin/series/no-colon", + "/admin/series/:empty-site", + "/admin/series/asura:", + "/admin/series/unknown-site:row", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Errorf("GET %s status = %d, want 404", path, rr.Code) + } + } +} + +// The title and the key line come from the database, so they must render +// escaped: a title that is markup stays markup in the response, never HTML. +func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:evil", Site: "asura", SeriesID: "evil", + Title: ``, SeriesURL: "https://asurascans.com/series/evil", + Kind: "manga", + }) + + req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "<script>") { + t.Errorf("title is not escaped:\n%s", body) + } + if strings.Contains(body, "', 'u', 'c', 9000)`); err != nil { + t.Fatalf("seed hostile title: %v", err) + } + srv := newRouter(st, testConfig(), nil) + + body := adminSeriesPage(t, srv, st, "") + if strings.Contains(body, "