Files
mangaBookmark/backend/internal/store/admin_test.go
T
sulthan fe2cd12049 feat(web,store,latest): forced poll — Check now stamps a Series for the next Lane pass (#146)
The control writes series.force_poll_at (column landed in migration 0014)
and never commands the poller: pending is derived as force_poll_at >
latest_checked_at and self-clears because the check stamp is written before
the fetch. The due query's forced flag overrides the rest cutoff, the
Sighting-deferral and finished-only clauses, jumps the queue, and wakes a
sleeping browser Lane; it never overrides an empty series_url, the Bookmarks
join, the refusal backoff, the sidecar-down skip or the Lane gap.

ADRs: 0013-commands-through-the-database.
2026-08-22 00:53:51 +07:00

397 lines
16 KiB
Go

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)
}
}