#142: series list page with eight hygiene filters, site/library narrowing, paging

This commit is contained in:
2026-08-21 19:40:45 +07:00
parent 503fb49d0a
commit e8a3c5f826
8 changed files with 832 additions and 18 deletions
+397
View File
@@ -1840,3 +1840,400 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
t.Fatal("novel tab=new should render the novel All list")
}
}
// seriesRowSeed is one series row (and optionally its bookmarks) for the
// Series list tests. Seeded with direct SQL because the store's own surface
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
// same reason the store's admin tests seed this way.
type seriesRowSeed 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
}
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
// fresh readers) with the exact admin-relevant facts a test needs.
func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed) {
t.Helper()
site, seriesID, ok := strings.Cut(seed.key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", seed.key)
}
if seed.kind == "" {
seed.kind = store.KindManga
}
var latestChapter any = ""
if seed.latestNum != nil {
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
}
if _, err := 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 = st.OwnerID()
if i > 0 {
readerID = seedReader(t, st)
}
if _, err := 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 := db.Exec(
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
st.OwnerID(), site, seriesID); err != nil {
t.Fatalf("seed raised-by %q: %v", seed.key, err)
}
}
}
// seedReader mints a fresh Reader for a second bookmark, so a series can carry
// a Reader count above one.
func seedReader(t *testing.T, st *store.Store) int64 {
t.Helper()
discordID := "seed-" + strconv.FormatInt(time.Now().UnixNano(), 10)
id, err := st.EnsureReader(discordID, [32]byte{})
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
return id
}
// adminSeriesPage drives one Series list request as the owner and returns the
// rendered body, failing the test on anything but a 200.
func adminSeriesPage(t *testing.T, srv http.Handler, st *store.Store, query string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/series"+query, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series%s status = %d, want 200", query, rr.Code)
}
return rr.Body.String()
}
// The filter select reaches the store as the wire constant and the heading
// states the same total the rows render: ?filter=no_cover renders only the
// no-cover row, its option label carries its library-wide count, and an
// unknown filter value is the absent All case, never an error.
func TestSeriesListFilterWiring(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: 9000, latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: 9000, latestNum: floatPtr(3), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
if !strings.Contains(body, "Title of asura:nocover") {
t.Errorf("no_cover list misses its row:\n%s", body)
}
if strings.Contains(body, "Title of asura:healthy") {
t.Errorf("no_cover list renders a covered row:\n%s", body)
}
if !strings.Contains(body, "1 series") || !strings.Contains(body, "No cover") {
t.Errorf("no_cover heading lacks the filtered count and name:\n%s", body)
}
if !strings.Contains(body, "No cover (1)") {
t.Errorf("the filter option label lacks its count:\n%s", body)
}
if !strings.Contains(body, `<option value="no_cover" selected>`) {
t.Errorf("the no_cover option is not selected:\n%s", body)
}
// An unknown filter value is the absent All case: both rows, no error.
body = adminSeriesPage(t, srv, st, "?filter=bogus")
if !strings.Contains(body, "Title of asura:healthy") || !strings.Contains(body, "Title of asura:nocover") {
t.Errorf("unknown filter does not render All series:\n%s", body)
}
if !strings.Contains(body, `<option value="all" selected>`) {
t.Errorf("the all option is not selected for an unknown filter:\n%s", body)
}
}
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
// kagane manga stale row renders, and every link the page emits carries the
// filter and Site so the narrowing survives in the URL.
func TestSeriesListSiteAndKindComposeWithFilter(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
stale := time.Now().Add(-24 * time.Hour).UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:want", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
if !strings.Contains(body, "Title of kagane:want") {
t.Errorf("stale+kagane+manga misses its row:\n%s", body)
}
for _, unwanted := range []string{"Title of kagane:novel", "Title of kagane:fresh", "Title of asura:stale"} {
if strings.Contains(body, unwanted) {
t.Errorf("stale+kagane+manga renders %q:\n%s", unwanted, body)
}
}
if !strings.Contains(body, "1 series") {
t.Errorf("heading lacks the narrowed count:\n%s", body)
}
// The narrowing survives in the URL: every emitted link carries the
// filter and Site, and the kind hidden input keeps it on select submits.
for _, want := range []string{
`href="/admin/series?filter=stale&amp;site=kagane"`,
`href="/admin/series?filter=stale&amp;kind=manga&amp;site=kagane"`,
`href="/admin/series?filter=stale&amp;kind=novel&amp;site=kagane"`,
`<input type="hidden" name="kind" value="manga">`,
} {
if !strings.Contains(body, want) {
t.Errorf("narrowing is lost from the URL; missing %q:\n%s", want, body)
}
}
if !strings.Contains(body, `<option value="stale" selected>`) {
t.Errorf("the stale filter is not kept selected:\n%s", body)
}
if !strings.Contains(body, `<option value="kagane" selected>`) {
t.Errorf("the kagane site is not kept selected:\n%s", body)
}
}
// More than one page of rows: page 1 and page 2 share no key, the pager range
// comes from the window total (not a second query), and a page past the end
// re-reads at page 1 rather than rendering an empty table. All 55 rows share
// a zero check stamp, so only the (site, series_id) tie-break keeps the page
// boundary stable.
func TestSeriesListPagingIsStable(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover_address,
latest_checked_at)
SELECT 'asura', 'bulk-' || g, 'Bulk ' || g, 'https://asurascans.com/comics/bulk-' || g, 'c', 0
FROM generate_series(1, 55) AS g`); err != nil {
t.Fatalf("bulk seed series: %v", err)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
SELECT $1, 'asura', 'bulk-' || g, 1000
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
t.Fatalf("bulk seed bookmarks: %v", err)
}
srv := newRouter(st, testConfig(), nil)
pageKeys := func(body string) map[string]bool {
out := map[string]bool{}
// parts[0] is the prelude before the first detail link; every later
// chunk starts with a key, so only those count.
for _, chunk := range strings.Split(body, `href="/admin/series/`)[1:] {
if i := strings.Index(chunk, `"`); i > 0 {
out[chunk[:i]] = true
}
}
return out
}
p1 := adminSeriesPage(t, srv, st, "")
p2 := adminSeriesPage(t, srv, st, "?page=2")
if !strings.Contains(p1, "1–50 of 55") {
t.Errorf("page 1 pager range wrong:\n%s", p1)
}
if !strings.Contains(p2, "51–55 of 55") {
t.Errorf("page 2 pager range wrong:\n%s", p2)
}
if !strings.Contains(p1, `href="/admin/series?page=2"`) {
t.Errorf("page 1 lacks a next link:\n%s", p1)
}
k1, k2 := pageKeys(p1), pageKeys(p2)
if len(k1) != 50 || len(k2) != 5 {
t.Fatalf("pages hold %d and %d rows, want 50 and 5", len(k1), len(k2))
}
for k := range k1 {
if k2[k] {
t.Errorf("row %q repeats across pages", k)
}
}
if len(k1)+len(k2) != 55 {
t.Errorf("%d distinct rows across pages, want 55 (a row vanished)", len(k1)+len(k2))
}
// A page past the end re-reads at page 1: the pager states the first
// page's range and the rows render rather than an empty table.
pOver := adminSeriesPage(t, srv, st, "?page=99")
if !strings.Contains(pOver, "1–50 of 55") || len(pageKeys(pOver)) != 50 {
t.Errorf("a page past the end does not re-read at page 1:\n%s", pOver)
}
}
// Page 2 of a one-page result re-reads at page 1: the store's window count
// only runs over the rows the result set carries, so an overflow page must
// not be rendered as an empty list.
func TestSeriesListPagePastEndReReadsAtPageOne(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?page=2")
if !strings.Contains(body, "1–3 of 3") {
t.Errorf("page 2 of a one-page result does not re-read at page 1:\n%s", body)
}
for _, want := range []string{"Title of asura:a", "Title of asura:b", "Title of asura:c"} {
if !strings.Contains(body, want) {
t.Errorf("page 2 of a one-page result dropped %q:\n%s", want, body)
}
}
if strings.Contains(body, `class="empty"`) {
t.Errorf("page 2 of a one-page result renders the empty state:\n%s", body)
}
}
// A filter matching nothing renders the named empty state, still 200, and
// keeps the filter selected: an empty hygiene list reads as good news rather
// than a broken page.
func TestSeriesListEmptyStateNamesTheFilter(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
if !strings.Contains(body, "No series") {
t.Errorf("a matching-nothing filter renders no named empty state:\n%s", body)
}
if !strings.Contains(body, "No cover") {
t.Errorf("the empty state does not name the filter:\n%s", body)
}
if !strings.Contains(body, `<option value="no_cover" selected>`) {
t.Errorf("the empty filter is not kept selected:\n%s", body)
}
if strings.Contains(body, `class="tbl series"`) {
t.Errorf("an empty list still renders the table:\n%s", body)
}
}
// A hostile title is escaped, not executed: titles come from the database and
// from third-party pages, so they are attacker-controlled.
func TestSeriesListEscapesHostileTitles(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover_address, latest_checked_at)
VALUES ('asura', 'xss', '<script>alert(1)</script>', '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, "<script>alert") {
t.Errorf("hostile title rendered unescaped:\n%s", body)
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Errorf("hostile title is not escaped:\n%s", body)
}
}
// Rows are the two-line form, banded by class (never nth-of-type), the site
// cell colours by class rather than inline style, chips cap at two plus a +N
// tail, and no ember token appears anywhere on the page.
func TestSeriesListRowShape(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// An orphan with no URL and no cover: three chips, capped to two plus a
// tail. The two clean rows sit on either side for the banding.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig(), nil)
body := adminSeriesPage(t, srv, st, "")
// Order is least-recently-checked first, then (site, series_id): broken
// (never checked), mid (stale), fine (fresh). The middle row carries the
// band class; the marked row the attention class.
if !(strings.Index(body, "Title of asura:broken") < strings.Index(body, "Title of asura:mid") &&
strings.Index(body, "Title of asura:mid") < strings.Index(body, "Title of asura:fine")) {
t.Errorf("rows are not in checked order:\n%s", body)
}
if strings.Count(body, `class="trow"`) != 1 {
t.Errorf("expected exactly one unmarked, unbanded row:\n%s", body)
}
if strings.Count(body, `class="trow attention"`) != 1 {
t.Errorf("expected exactly one attention row:\n%s", body)
}
if strings.Count(body, `class="trow attention band"`) != 1 {
t.Errorf("expected exactly one attention band row:\n%s", body)
}
// The orphan's chips cap at two plus the +N tail.
if !strings.Contains(body, `<span class="mark">no URL</span><span class="mark">no cover</span><span class="mark mark-faint">+1</span>`) {
t.Errorf("chips do not cap at two plus a tail:\n%s", body)
}
// The site cell is a class, never the design's inline style.
if strings.Contains(body, `style="color:var(--`) {
t.Errorf("a site cell carries an inline style:\n%s", body)
}
if !strings.Contains(body, `class="c-site site-asura"`) {
t.Errorf("the site cell lacks its site class:\n%s", body)
}
// The action cell is present and empty for the sibling ticket, and no
// Remove control or confirm row renders in this batch.
if !strings.Contains(body, `<span class="c-act"></span>`) {
t.Errorf("the action cell is not present and empty:\n%s", body)
}
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
}
// No ember: the new-chapter signal stays off the admin surface. Scoped to
// the page content — the shell's brand mark legitimately wears the ember
// flame on every page, admin or not.
mainStart := strings.Index(body, `<main class="page admin-page">`)
mainEnd := strings.Index(body, `</main>`)
if mainStart < 0 || mainEnd < 0 || mainStart > mainEnd {
t.Fatalf("no page content region to check:\n%s", body)
}
if strings.Contains(body[mainStart:mainEnd], "--ember") {
t.Errorf("the page content carries an ember token:\n%s", body)
}
}