feat(store): cross-series admin read model with privacy in the projection (#140)
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.
This commit is contained in:
@@ -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 ("<site>:<series_id>").
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user