From 09a094ca675c0866a0473651366c09c9b53376ec Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 19:42:45 +0700 Subject: [PATCH] fix(web): detail row lookup walks all pages of the Site read (#144) --- backend/internal/web/admin_series_detail.go | 34 +++++++++++++-------- backend/web_test.go | 34 ++++++++++++++++++++- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 536333a..085565b 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -36,27 +36,35 @@ type seriesDetailView struct { // 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. +// projection is the privacy boundary, and a dedicated single-row read would +// be a second definition of it. +// ponytail: a page scan per detail view, one query per page of the Site's +// rows up to the window total; a keyed read alongside SeriesPage when the +// library outgrows the page size. func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) { site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") if !ok || site == "" || seriesID == "" { http.NotFound(w, r) return } - 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])}) + seen := 0 + for page := 1; ; page++ { + p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page}) + if err != nil { + log.Printf("series detail %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) return } + seen += len(p.Rows) + for i := range p.Rows { + if p.Rows[i].SeriesID == seriesID { + h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(p.Rows[i])}) + return + } + } + if seen >= p.Total { + break + } } http.NotFound(w, r) } diff --git a/backend/web_test.go b/backend/web_test.go index 492ce77..e993cb2 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -1881,7 +1881,7 @@ func TestAdminSeriesDetailRendersFacts(t *testing.T) { "SP Baby", "kagane:sp-baby · kagane · manga", "ch 45", - "checked 2h0m0s ago", + "checked 2h0m", "2 readers", } { if !strings.Contains(body, want) { @@ -2009,6 +2009,38 @@ func TestAdminSeriesDetailUnknownKey404(t *testing.T) { } } +// A Series past the first page of its Site's read (50 rows) must still +// render: the list shows it, so its detail link may not answer 404. The row +// lookup walks the window total, not just page 1. +func TestAdminSeriesDetailBeyondFirstPage(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + for i := 0; i < 55; i++ { + seed(t, st, store.Bookmark{ + Key: fmt.Sprintf("asura:s%03d", i), Site: "asura", SeriesID: fmt.Sprintf("s%03d", i), + Title: "Bulk", SeriesURL: "https://asurascans.com/series/bulk", + Kind: "manga", + }) + } + // Rows order by (latest_checked_at, site, series_id), all zero stamps, so + // "zzz" lands on page 2 behind the fifty-five "s*" rows. + seed(t, st, store.Bookmark{ + Key: "asura:zzz", Site: "asura", SeriesID: "zzz", + Title: "Late", SeriesURL: "https://asurascans.com/series/zzz", + Kind: "manga", + }) + + req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:zzz", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /admin/series/asura:zzz status = %d, want 200 for a page-2 row", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Late") { + t.Errorf("page-2 row did not render:\n%s", rr.Body.String()) + } +} + // 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) {