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) } } } // ForceSeriesPoll is the idempotent stamp write: a second press overwrites // the request time, and touching a missing series is not an error. func TestForceSeriesPollStampsIdempotently(t *testing.T) { s := newTestStore(t) seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1}) if err := s.ForceSeriesPoll("asura", "x", 42); err != nil { t.Fatalf("ForceSeriesPoll: %v", err) } if err := s.ForceSeriesPoll("asura", "x", 99); err != nil { t.Fatalf("ForceSeriesPoll re-stamp: %v", err) } // Touching a missing series is not an error: the row may have been // orphaned, and the caller's read decides what exists. if err := s.ForceSeriesPoll("asura", "ghost", 99); err != nil { t.Fatalf("ForceSeriesPoll missing: %v", err) } var got int64 if err := s.db.QueryRow( `SELECT force_poll_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil { t.Fatalf("read force_poll_at: %v", err) } if got != 99 { t.Fatalf("force_poll_at = %d, want 99 (the later press wins)", got) } } // The admin projection carries the force stamp so the web layer can derive // the pending flag without a second read. func TestAdminSeriesCarriesForcePollAt(t *testing.T) { s := newTestStore(t) seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1}) if err := s.ForceSeriesPoll("asura", "x", 5000); err != nil { t.Fatalf("ForceSeriesPoll: %v", err) } page, err := s.SeriesPage(SeriesFilter{}) if err != nil { t.Fatalf("SeriesPage: %v", err) } if len(page.Rows) != 1 || page.Rows[0].ForcePollAt != 5000 { t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows) } }