From e0b9063d9ef69a06df396e9da732ca064cb4484f Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 18:20:59 +0700 Subject: [PATCH] feat(store): cross-series admin read model with privacy in the projection (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeriesPage returns one 50-row page of Series matching one of eight named hygiene filters (all, no_series_url, never_read_a_chapter, no_readers, never_checked, stale, no_cover, reader_report), with a window-count total; SeriesShapes returns the per-Site aggregate, one grouped pass. The compound filter value object takes Site, Kind, Name, a caller-supplied staleness cutoff, and a 1-based page. The privacy boundary lives in the projection: adminSeriesColumns never selects latest_raised_by, and AdminSeries has no field for it — a SQL-computed boolean is all that crosses. LEFT JOIN surfaces orphans (reader_count 0); (site, series_id) tie-breaks the zero-stamp boundary so pages stay stable. Also lands 0014: the series(latest_checked_at) index and series.force_poll_at, both expand-step schema for later tickets in the series. --- backend/internal/store/admin.go | 271 ++++++++++++++ backend/internal/store/admin_test.go | 350 ++++++++++++++++++ .../migrations/0014_admin_read_model.sql | 11 + 3 files changed, 632 insertions(+) create mode 100644 backend/internal/store/admin.go create mode 100644 backend/internal/store/admin_test.go create mode 100644 backend/internal/store/migrations/0014_admin_read_model.sql diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go new file mode 100644 index 0000000..24e05fd --- /dev/null +++ b/backend/internal/store/admin.go @@ -0,0 +1,271 @@ +package store + +import ( + "database/sql" + "fmt" + "strconv" + "strings" +) + +// Series filter names (issue #140), ordered permanent-then-fixable — the +// repairs nothing will ever undo first, the ones a Poll can make right after. +// A name is the repair a row needs, not the SQL that finds it; the values are +// the wire form the Series list URL carries (#142). "all" is the absent and +// unknown case: every Series. +const ( + SeriesFilterAll = "all" + SeriesFilterNoURL = "no_series_url" + SeriesFilterNoChapter = "never_read_a_chapter" + SeriesFilterNoReaders = "no_readers" + SeriesFilterNeverChecked = "never_checked" + SeriesFilterStale = "stale" + SeriesFilterNoCover = "no_cover" + SeriesFilterReaderReport = "reader_report" +) + +// SeriesFilter is one named hygiene predicate over the whole library. Site +// and Kind narrow the row read; Name picks the predicate; Cutoff is the +// staleness boundary the "stale" filter compares against, supplied by the +// caller's clock — the store has no clock; Page is 1-based. +type SeriesFilter struct { + Site string // "" = every Site + Kind string // "" = both library buckets' series + Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll + Cutoff int64 // unix ms; "stale" reads it, the store never does + Page int // 1-based page of the row read; default 1 +} + +// adminSeriesColumns is the owner's library-wide Series projection in +// scanAdminSeries order. It is the privacy boundary: a Series' row carries +// the Reader id that raised its Latest Chapter (latest_raised_by), and that id +// must never leave the store package — so the projection does not select it, +// and only the anonymous boolean in raisedByReaderAnswer crosses it. +const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, + s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at` + +// raisedByReaderAnswer answers "did a Reader's report set this number" without +// naming which Reader. Kept apart from adminSeriesColumns so the column list — +// the shape scanAdminSeries is fed — stays free of the Sighting-raiser +// identity, and the owner learns which rows to act on and nothing about the +// Reader behind them. +const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader` + +// seriesPageSize is the row read's page length. The tie-break in the query's +// ORDER BY is what makes this a stable page boundary — see SeriesPage. +const seriesPageSize = 50 + +// AdminSeries is one Series as the owner's library-wide view sees it: a +// Series-level fact plus an anonymous Reader count. ReaderCount being zero is +// the orphan marker. RaisedByReader is the only trace of the Sighting +// mechanism here; the Reader id behind it never reaches this type. +type AdminSeries struct { + Site string + SeriesID string + Title string + SeriesURL string + CoverAddress string // "" = no Cover yet + Kind string + LatestChapter string + LatestChapterNum *float64 // nil until first captured + LatestCheckedAt int64 + ReaderCount int + RaisedByReader bool // a Reader's report set LatestChapterNum +} + +// SeriesPage is one page of the owner's filtered Series list plus the count +// of every Series matching the same filter — a window number, not the page's +// len, so the landing page's figure and the list heading come from one query. +type SeriesPage struct { + Rows []AdminSeries + Total int +} + +// SiteSeriesShape is one Site's share of the Series matching a filter: how +// many, and the manga/novel split. One grouped pass, then library-wide totals +// are summed in Go over the rows — the landing page's per-Site table reads +// this and never pays for the rows the list discards. +type SiteSeriesShape struct { + Site string + Total int + Manga int + Novel int +} + +// Key returns the canonical identity in bookmark-key form (":"). +func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID } + +// adminFilter maps a filter's named predicate to its compile-time WHERE and +// HAVING clauses and their bound parameters — the name never reaches query +// text, and Site and Kind bind as parameters. Shared by the row read and the +// per-Site aggregate so the two cannot disagree on what a filter means. +// +// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch- +// gate case is invisible to SQL, needs the Site registry in Go, and belongs to +// a later repair), never-read-a-chapter and never-checked as disjoint halves +// (non-zero versus zero check stamp), stale, no cover, and Reader-report. +// no_readers is the one HAVING predicate: it is the orphan test, an aggregate +// over the LEFT JOIN, where a bare WHERE has no row to test. +// +// stale is the checked-but-old half of the stamp partition — because the +// verdict line wants "not checked in twelve hours" as one figure, and a never +// checked Series is already counted on its own "waiting"/never-checked +// filter, folding it in would double-report it. The landing page computes the +// inclusive number as stale + never-checked. +func adminFilter(f SeriesFilter) (where, having string, args []any, err error) { + var clauses []string + if f.Kind != "" { + args = append(args, f.Kind) + clauses = append(clauses, "s.kind = $"+strconv.Itoa(len(args))) + } + switch f.Name { + case "", SeriesFilterAll: + case SeriesFilterNoURL: + clauses = append(clauses, `s.series_url = ''`) + case SeriesFilterNoChapter: + clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`) + case SeriesFilterNeverChecked: + clauses = append(clauses, `s.latest_checked_at = 0`) + case SeriesFilterStale: + clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)) + args = append(args, f.Cutoff) + case SeriesFilterNoCover: + clauses = append(clauses, `s.cover_address = ''`) + case SeriesFilterReaderReport: + clauses = append(clauses, `s.latest_raised_by IS NOT NULL`) + case SeriesFilterNoReaders: + having = `HAVING COUNT(b.reader_id) = 0` + default: + return "", "", nil, fmt.Errorf("unknown series filter %q", f.Name) + } + if len(clauses) > 0 { + where = "WHERE " + strings.Join(clauses, " AND ") + } + return where, having, args, nil +} + +// SeriesPage returns one page of the Series matching the filter, least +// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the +// orphans that hygiene has to find — an inner join would hide them, exactly +// as the Lane's join does. ReaderCount is a plain count of every Bookmark on +// the Series, which knowingly disagrees with the two Lane queries for as long +// as the finished lifecycle bucket exists (#140). +// +// The tie-break is mandatory, not decorative: every unpollable Series shares a +// zero check stamp, so ordering on that column alone gives no stable page +// boundary and rows would repeat or vanish across pages. (site, series_id) is +// the primary key, hence total. The filtered total is a window count in the +// same query — window functions run after grouping and before the limit, so +// one where-clause cannot disagree with a second copy of itself. +func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { + where, having, args, err := adminFilter(f) + if err != nil { + return SeriesPage{}, err + } + if f.Page < 1 { + f.Page = 1 + } + // Site narrowing is the row read's own; the aggregate must see every Site. + if f.Site != "" { + args = append(args, f.Site) + clause := "s.site = $" + strconv.Itoa(len(args)) + if where == "" { + where = "WHERE " + clause + } else { + where += " AND " + clause + } + } + base := len(args) + args = append(args, seriesPageSize, seriesPageSize*(f.Page-1)) + rows, err := s.db.Query(` + SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, + COUNT(b.reader_id) AS reader_count, + COUNT(*) OVER () AS filtered_total + FROM series s + LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id + `+where+` + GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, + s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, + s.latest_raised_by + `+having+` + ORDER BY s.latest_checked_at, s.site, s.series_id + LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) + if err != nil { + return SeriesPage{}, fmt.Errorf("query series page: %w", err) + } + defer rows.Close() + + out := SeriesPage{} + for rows.Next() { + a, total, err := scanAdminSeries(rows.Scan) + if err != nil { + return SeriesPage{}, fmt.Errorf("scan series page: %w", err) + } + out.Rows = append(out.Rows, a) + out.Total = total + } + return out, rows.Err() +} + +// SeriesShapes returns each Site's share of the Series matching the filter, +// one grouped pass. Site and Page are row-read concerns and are ignored; the +// Landing page reads this per Site and sums the totals in Go for the +// library-wide figure. +func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) { + where, having, args, err := adminFilter(f) + if err != nil { + return nil, err + } + rows, err := s.db.Query(` + SELECT site, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE kind = 'manga') AS manga, + COUNT(*) FILTER (WHERE kind = 'novel') AS novel + FROM ( + SELECT s.site, s.kind + FROM series s + LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id + `+where+` + GROUP BY s.site, s.series_id, s.kind + `+having+` + ) shape + GROUP BY site + ORDER BY site`, args...) + if err != nil { + return nil, fmt.Errorf("query series shapes: %w", err) + } + defer rows.Close() + + out := []SiteSeriesShape{} + for rows.Next() { + var sh SiteSeriesShape + if err := rows.Scan(&sh.Site, &sh.Total, &sh.Manga, &sh.Novel); err != nil { + return nil, fmt.Errorf("scan series shape: %w", err) + } + out = append(out, sh) + } + return out, rows.Err() +} + +// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +// order, plus the query's reader_count and filtered_total columns, and returns +// the window total alongside the row. latest_chapter_num is NULL until first +// captured — the "never read a chapter" state. The Sighting-raiser column is +// never among the scanned columns. +func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) { + var ( + a AdminSeries + latestChapterNum sql.NullFloat64 + total int + ) + if err := scan( + &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, + &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, + &a.RaisedByReader, &a.ReaderCount, &total, + ); err != nil { + return AdminSeries{}, 0, err + } + if latestChapterNum.Valid { + a.LatestChapterNum = &latestChapterNum.Float64 + } + return a, total, nil +} diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go new file mode 100644 index 0000000..8a62d55 --- /dev/null +++ b/backend/internal/store/admin_test.go @@ -0,0 +1,350 @@ +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) + } + } +} diff --git a/backend/internal/store/migrations/0014_admin_read_model.sql b/backend/internal/store/migrations/0014_admin_read_model.sql new file mode 100644 index 0000000..af059e5 --- /dev/null +++ b/backend/internal/store/migrations/0014_admin_read_model.sql @@ -0,0 +1,11 @@ +-- Admin read-model foundation (#140). The Series list's default order is +-- least-recently-checked first, so the table — which has only its primary key +-- today — gets an index that can serve it. A grouped query over a join may +-- ignore the index, so this is a judgement, not a measurement: re-time on real +-- data before adding a second. +CREATE INDEX series_latest_checked_at_idx ON series (latest_checked_at); + +-- force_poll_at is the "ask for one Series to be checked now" stamp (#146). +-- Zero means never forced; nothing reads the column before that ticket wires +-- it, so it lands here unused. +ALTER TABLE series ADD COLUMN force_poll_at bigint NOT NULL DEFAULT 0;