#142: series list page with eight hygiene filters, site/library narrowing, paging
This commit is contained in:
@@ -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&site=kagane"`,
|
||||
`href="/admin/series?filter=stale&kind=manga&site=kagane"`,
|
||||
`href="/admin/series?filter=stale&kind=novel&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, "<script>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user