Add comix.to and kagane.to support (#13)
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Bookmark is one tracked series, keyed "<site>:<series_id>" across both sites.
|
||||
//
|
||||
// LastChapter* is the user's read progress; LatestChapter* is the newest
|
||||
// chapter the site has published, captured opportunistically by the userscript.
|
||||
type Bookmark struct {
|
||||
Key string `json:"key"`
|
||||
Site string `json:"site"`
|
||||
SeriesID string `json:"series_id"`
|
||||
Title string `json:"title"`
|
||||
SeriesURL string `json:"series_url"`
|
||||
Cover string `json:"cover"`
|
||||
LastChapter string `json:"last_chapter"`
|
||||
LastChapterNum float64 `json:"last_chapter_num"`
|
||||
LastChapterURL string `json:"last_chapter_url"`
|
||||
Favorite bool `json:"favorite"`
|
||||
LatestChapter string `json:"latest_chapter"`
|
||||
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
||||
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
||||
// Status is the lifecycle bucket: reading, archived, or finished.
|
||||
// Archived series stay polled for new chapters; finished ones do not.
|
||||
// Empty on the way in means "no opinion" — see Upsert.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// HasNewChapter reports whether the site has published past the read point.
|
||||
// A nil LatestChapterNum means nothing has been captured yet, which is not the
|
||||
// same as "nothing new".
|
||||
func (b Bookmark) HasNewChapter() bool {
|
||||
return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum
|
||||
}
|
||||
|
||||
// chapterLeadIn matches the prefix the userscript and the poller both write
|
||||
// ("Chapter 250"), so the UI can add exactly one "Ch " of its own instead of
|
||||
// doubling it. A manual edit through the web UI stores a bare "250", which is
|
||||
// the same string minus the lead-in.
|
||||
var chapterLeadIn = regexp.MustCompile(`(?i)^\s*(?:chapter|ch\.?)\s*`)
|
||||
|
||||
func displayChapter(raw string, num float64) string {
|
||||
rest := strings.TrimSpace(chapterLeadIn.ReplaceAllString(raw, ""))
|
||||
if rest == "" {
|
||||
rest = strconv.FormatFloat(num, 'f', -1, 64)
|
||||
}
|
||||
// "Ch " only makes sense in front of a number; anything else is a label the
|
||||
// site gave us, so pass it through as written.
|
||||
if rest[0] < '0' || rest[0] > '9' {
|
||||
return rest
|
||||
}
|
||||
return "Ch " + rest
|
||||
}
|
||||
|
||||
// DisplayChapter is the read-progress line: one canonical "Ch N" whatever
|
||||
// format the write came in as.
|
||||
func (b Bookmark) DisplayChapter() string {
|
||||
return displayChapter(b.LastChapter, b.LastChapterNum)
|
||||
}
|
||||
|
||||
// DisplayLatest is the same for the newest published chapter, which arrives
|
||||
// with the same "Chapter N" lead-in from both the userscript and the poller.
|
||||
func (b Bookmark) DisplayLatest() string {
|
||||
var num float64
|
||||
if b.LatestChapterNum != nil {
|
||||
num = *b.LatestChapterNum
|
||||
}
|
||||
return displayChapter(b.LatestChapter, num)
|
||||
}
|
||||
|
||||
// ContinueURL is where the Continue button points: the chapter last read, or
|
||||
// the series page when no chapter URL was ever captured.
|
||||
func (b Bookmark) ContinueURL() string {
|
||||
if b.LastChapterURL != "" {
|
||||
return b.LastChapterURL
|
||||
}
|
||||
return b.SeriesURL
|
||||
}
|
||||
|
||||
// Initial is the monogram the web UI shows in place of a cover when the
|
||||
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
||||
// the title is missing, so the slot is never empty.
|
||||
func (b Bookmark) Initial() string {
|
||||
for _, r := range b.Title {
|
||||
return strings.ToUpper(string(r))
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||
const (
|
||||
StatusReading = "reading"
|
||||
StatusArchived = "archived"
|
||||
StatusFinished = "finished"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
key TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
series_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
series_url TEXT,
|
||||
cover TEXT,
|
||||
last_chapter TEXT,
|
||||
last_chapter_num REAL,
|
||||
last_chapter_url TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
latest_chapter TEXT NOT NULL DEFAULT '',
|
||||
latest_chapter_num REAL,
|
||||
latest_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'reading',
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
// The columns above that databases created before them will be missing.
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS, so each is added only when absent.
|
||||
var addedColumns = []struct{ name, ddl string }{
|
||||
{"favorite", `ALTER TABLE bookmarks ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0`},
|
||||
{"latest_chapter", `ALTER TABLE bookmarks ADD COLUMN latest_chapter TEXT NOT NULL DEFAULT ''`},
|
||||
{"latest_chapter_num", `ALTER TABLE bookmarks ADD COLUMN latest_chapter_num REAL`},
|
||||
// When the server last looked at this series, unix ms; 0 means never, and
|
||||
// sorts first so a new bookmark is picked up on the next tick with no
|
||||
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
|
||||
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`},
|
||||
// Lifecycle bucket. The DEFAULT backfills every pre-existing row as
|
||||
// 'reading', so there is no separate migration step.
|
||||
{"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`},
|
||||
}
|
||||
|
||||
const bookmarkColumns = `key, site, series_id, title, series_url, cover,
|
||||
last_chapter, last_chapter_num, last_chapter_url,
|
||||
favorite, latest_chapter, latest_chapter_num, updated_at, status`
|
||||
|
||||
// Store is the SQLite-backed bookmark store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
||||
func Open(path string) (*Store, error) {
|
||||
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
||||
// concurrent requests; a single writer connection keeps writes serialized.
|
||||
dsn := path
|
||||
if !strings.Contains(dsn, "?") {
|
||||
dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
if err := migrateColumns(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
||||
}
|
||||
if err := migrateAsuraKeys(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate asura keys: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// migrateColumns brings a pre-existing bookmarks table up to the current
|
||||
// schema. Safe to run on every start: columns already present are skipped.
|
||||
func migrateColumns(db *sql.DB) error {
|
||||
have, err := existingColumns(db, "bookmarks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range addedColumns {
|
||||
if _, ok := have[c.name]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(c.ddl); err != nil {
|
||||
return fmt.Errorf("add column %q: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AsuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura
|
||||
// appends to every series slug. It rotates on each site redeploy, so it
|
||||
// must not be part of series_id. Must stay in sync with stripBuildHash in
|
||||
// userscript/manga-bookmark.user.js.
|
||||
var AsuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`)
|
||||
|
||||
// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries
|
||||
// the build hash to the stable, hashless ID. Rows keyed with a hash are
|
||||
// orphaned on every Asura redeploy (old-hash URLs 302 to new-hash ones, so
|
||||
// detection yields a key that never matches). When two hash-generations of
|
||||
// one series collide, the row with the newest updated_at wins and the rest
|
||||
// are deleted. Idempotent: hashless IDs never match the regex.
|
||||
func migrateAsuraKeys(db *sql.DB) error {
|
||||
rows, err := db.Query(`SELECT key, series_id, updated_at FROM bookmarks WHERE site = 'asura'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list asura rows: %w", err)
|
||||
}
|
||||
type row struct {
|
||||
key, id string
|
||||
updated int64
|
||||
}
|
||||
var all []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.key, &r.id, &r.updated); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan asura row: %w", err)
|
||||
}
|
||||
all = append(all, r)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groups := map[string][]row{}
|
||||
for _, r := range all {
|
||||
stripped := AsuraBuildHash.ReplaceAllString(r.id, "")
|
||||
groups[stripped] = append(groups[stripped], r)
|
||||
}
|
||||
for stripped, g := range groups {
|
||||
winner := 0
|
||||
for i := range g {
|
||||
if g[i].updated > g[winner].updated {
|
||||
winner = i
|
||||
}
|
||||
}
|
||||
// Losers go first: rewriting the winner to the stripped key while a
|
||||
// pre-existing hashless row still holds it is a primary-key collision.
|
||||
for i, r := range g {
|
||||
if i == winner {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM bookmarks WHERE key = ?`, r.key); err != nil {
|
||||
return fmt.Errorf("drop duplicate %q: %w", r.key, err)
|
||||
}
|
||||
}
|
||||
if r := g[winner]; r.id != stripped {
|
||||
if _, err := db.Exec(
|
||||
`UPDATE bookmarks SET key = ?, series_id = ? WHERE key = ?`,
|
||||
"asura:"+stripped, stripped, r.key); err != nil {
|
||||
return fmt.Errorf("rewrite key %q: %w", r.key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingColumns(db *sql.DB, table string) (map[string]struct{}, error) {
|
||||
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s columns: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("scan column name: %w", err)
|
||||
}
|
||||
out[name] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// scanBookmark reads one row in bookmarkColumns order, translating SQLite's
|
||||
// integer bool and nullable latest_chapter_num into Go types.
|
||||
//
|
||||
// The optional columns are read through Null* types because rows predating
|
||||
// this code (or written by hand) may hold NULL where the app only ever writes
|
||||
// zero values. Only latest_chapter_num distinguishes the two: everywhere else
|
||||
// NULL and the zero value mean the same thing to clients.
|
||||
func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
var (
|
||||
b Bookmark
|
||||
title, seriesURL, cover sql.NullString
|
||||
lastChapter, lastChapterURL, latestChapter sql.NullString
|
||||
status sql.NullString
|
||||
lastChapterNum, latestChapterNum sql.NullFloat64
|
||||
favorite sql.NullInt64
|
||||
)
|
||||
if err := scan(
|
||||
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
|
||||
&lastChapter, &lastChapterNum, &lastChapterURL,
|
||||
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status,
|
||||
); err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
b.Title = title.String
|
||||
b.SeriesURL = seriesURL.String
|
||||
b.Cover = cover.String
|
||||
b.LastChapter = lastChapter.String
|
||||
b.LastChapterNum = lastChapterNum.Float64
|
||||
b.LastChapterURL = lastChapterURL.String
|
||||
b.Favorite = favorite.Int64 != 0
|
||||
b.LatestChapter = latestChapter.String
|
||||
if latestChapterNum.Valid {
|
||||
b.LatestChapterNum = &latestChapterNum.Float64
|
||||
}
|
||||
// A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would
|
||||
// leave the row in no list at all, so anything outside the three known
|
||||
// buckets reads as the default rather than being passed through.
|
||||
b.Status = status.String
|
||||
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
||||
b.Status = StatusReading
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database handle.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// List returns every bookmark, newest activity first.
|
||||
func (s *Store) List() ([]Bookmark, error) {
|
||||
rows, err := s.db.Query(`SELECT ` + bookmarkColumns + `
|
||||
FROM bookmarks
|
||||
ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query bookmarks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Bookmark{}
|
||||
for rows.Next() {
|
||||
b, err := scanBookmark(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan bookmark: %w", err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get returns one bookmark by key. A missing key is not an error: ok is false
|
||||
// and err is nil. UI mutations read-modify-write through this so they preserve
|
||||
// the fields they do not touch.
|
||||
func (s *Store) Get(key string) (Bookmark, bool, error) {
|
||||
b, err := scanBookmark(s.db.QueryRow(
|
||||
`SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, key).Scan)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Bookmark{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Bookmark{}, false, fmt.Errorf("get %q: %w", key, err)
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Upsert inserts or replaces a bookmark by key (last-write-wins) and returns
|
||||
// the row as actually stored.
|
||||
//
|
||||
// b.UpdatedAt is only a candidate: it is applied when the row is new or when
|
||||
// last_chapter_num changes, and otherwise the stored value is kept. Clients
|
||||
// order their list by updated_at, so favoriting a series or recording a newly
|
||||
// published chapter must not disturb that order — only real reading progress
|
||||
// does. Callers must therefore use the returned bookmark, not the argument.
|
||||
func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return Bookmark{}, fmt.Errorf("begin %q: %w", b.Key, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var latestNum any
|
||||
if b.LatestChapterNum != nil {
|
||||
latestNum = *b.LatestChapterNum
|
||||
}
|
||||
|
||||
// IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column
|
||||
// is the stored row and excluded.* is the incoming one; a brand-new key
|
||||
// never reaches this clause, so it keeps the fresh timestamp from VALUES.
|
||||
//
|
||||
// The status column resolves on the VALUES side, not in the conflict
|
||||
// clause: excluded.* is the row *after* these expressions are evaluated,
|
||||
// so a default applied there would look identical to a real 'reading' and
|
||||
// would overwrite an archived row on every PUT from a client that knows
|
||||
// nothing about the column. Resolved once here, an empty incoming status
|
||||
// means "keep what is stored", and only a brand-new row falls through to
|
||||
// the literal default. The subquery runs inside this transaction, so it
|
||||
// sees the row this statement is about to conflict with.
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO bookmarks (`+bookmarkColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
||||
series_url=excluded.series_url, cover=excluded.cover,
|
||||
last_chapter=excluded.last_chapter, last_chapter_num=excluded.last_chapter_num,
|
||||
last_chapter_url=excluded.last_chapter_url,
|
||||
favorite=excluded.favorite,
|
||||
latest_chapter=excluded.latest_chapter,
|
||||
latest_chapter_num=excluded.latest_chapter_num,
|
||||
status=excluded.status,
|
||||
updated_at=CASE
|
||||
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
|
||||
THEN excluded.updated_at
|
||||
ELSE bookmarks.updated_at
|
||||
END`,
|
||||
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
||||
b.LastChapter, b.LastChapterNum, b.LastChapterURL,
|
||||
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt,
|
||||
b.Status, b.Key); err != nil {
|
||||
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
|
||||
}
|
||||
|
||||
stored, err := scanBookmark(tx.QueryRow(
|
||||
`SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, b.Key).Scan)
|
||||
if err != nil {
|
||||
return Bookmark{}, fmt.Errorf("read back %q: %w", b.Key, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Bookmark{}, fmt.Errorf("commit %q: %w", b.Key, err)
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// Delete removes a bookmark by key. Deleting a missing key is not an error.
|
||||
func (s *Store) Delete(key string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM bookmarks WHERE key = ?`, key); err != nil {
|
||||
return fmt.Errorf("delete %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueForLatestCheck returns bookmarks whose server-side latest-chapter check has
|
||||
// aged past cutoffMs, least-recently-checked first, at most limit of them.
|
||||
//
|
||||
// Oldest-first is what keeps the poller fair when the backlog outgrows its
|
||||
// throughput: the most neglected series is always next, so a large collection
|
||||
// refreshes uniformly slower rather than leaving a tail that never refreshes at
|
||||
// all. The userscript sorts its own queue the same way (L453).
|
||||
//
|
||||
// Bookmarks with no series_url are skipped — there is nothing to fetch, which
|
||||
// is the same filter the userscript applies at L452.
|
||||
//
|
||||
// Finished series are excluded: nothing more is coming, so fetching them only
|
||||
// burns requests. Archived ones are deliberately still polled — knowing what a
|
||||
// shelved series is up to is the whole reason for archiving instead of deleting.
|
||||
func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) {
|
||||
rows, err := s.db.Query(`SELECT `+bookmarkColumns+`
|
||||
FROM bookmarks
|
||||
WHERE series_url IS NOT NULL AND series_url <> ''
|
||||
AND status IS NOT 'finished'
|
||||
AND latest_checked_at <= ?
|
||||
ORDER BY latest_checked_at ASC
|
||||
LIMIT ?`, cutoffMs, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query due bookmarks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Bookmark{}
|
||||
for rows.Next() {
|
||||
b, err := scanBookmark(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan due bookmark: %w", err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkLatestChecked records that the server looked at key at ts, whatever the
|
||||
// look turned up. Marking a missing key is not an error: the row may have been
|
||||
// deleted while a fetch was in flight.
|
||||
//
|
||||
// This is the one write that does not go through Upsert, and the column is kept
|
||||
// out of bookmarkColumns on purpose. PUT /bookmarks/{key} decodes a whole
|
||||
// Bookmark from the client and Upsert writes every column it knows about, so a
|
||||
// userscript PUT — which has no idea this field exists — would write a zero and
|
||||
// reset the cooldown, making the poller re-fetch that series every tick for as
|
||||
// long as the user kept reading it.
|
||||
func (s *Store) MarkLatestChecked(key string, ts int64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE bookmarks SET latest_checked_at = ? WHERE key = ?`, ts, key); err != nil {
|
||||
return fmt.Errorf("mark checked %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||
// tests outside this package (the poller's own tests assert on cooldown
|
||||
// bookkeeping) — see MarkLatestChecked for why the field itself stays off
|
||||
// Bookmark.
|
||||
func (s *Store) LatestCheckedAt(key string) (int64, error) {
|
||||
var ts int64
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT latest_checked_at FROM bookmarks WHERE key = ?`, key).Scan(&ts); err != nil {
|
||||
return 0, fmt.Errorf("latest checked at %q: %w", key, err)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
store, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
||||
|
||||
legacy, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
if _, err := legacy.Exec(`
|
||||
CREATE TABLE bookmarks (
|
||||
key TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
series_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
series_url TEXT,
|
||||
cover TEXT,
|
||||
last_chapter TEXT,
|
||||
last_chapter_num REAL,
|
||||
last_chapter_url TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("create legacy schema: %v", err)
|
||||
}
|
||||
if _, err := legacy.Exec(`
|
||||
INSERT INTO bookmarks (key, site, series_id, title, last_chapter, last_chapter_num, updated_at)
|
||||
VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 'Chapter 7', 7, 123)`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
if err := legacy.Close(); err != nil {
|
||||
t.Fatalf("close legacy db: %v", err)
|
||||
}
|
||||
|
||||
store, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore on legacy db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
list, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Key != "asura:legacy" {
|
||||
t.Fatalf("legacy row lost: %+v", list)
|
||||
}
|
||||
got := list[0]
|
||||
if got.Title != "Legacy Series" || got.LastChapterNum != 7 || got.UpdatedAt != 123 {
|
||||
t.Fatalf("legacy data mangled: %+v", got)
|
||||
}
|
||||
if got.Favorite || got.LatestChapter != "" || got.LatestChapterNum != nil {
|
||||
t.Fatalf("new columns should default empty, got %+v", got)
|
||||
}
|
||||
|
||||
// Reopening an already-migrated database must be a no-op, not an error.
|
||||
store2, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore is not idempotent: %v", err)
|
||||
}
|
||||
store2.Close()
|
||||
}
|
||||
|
||||
func TestStoreGet(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if _, err := store.Upsert(Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := store.Get("asura:solo")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("Get ok = false, want true")
|
||||
}
|
||||
if got.Title != "Solo Leveling" || got.LastChapterNum != 45 {
|
||||
t.Fatalf("Get = %+v, want title/chapter preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetMissing(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
_, ok, err := store.Get("asura:nope")
|
||||
if err != nil {
|
||||
t.Fatalf("Get missing returned error %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("Get ok = true for missing key, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookmarkHasNewChapter(t *testing.T) {
|
||||
num := func(f float64) *float64 { return &f }
|
||||
cases := []struct {
|
||||
name string
|
||||
b Bookmark
|
||||
want bool
|
||||
}{
|
||||
{"latest ahead", Bookmark{LastChapterNum: 45, LatestChapterNum: num(47)}, true},
|
||||
{"latest equal", Bookmark{LastChapterNum: 45, LatestChapterNum: num(45)}, false},
|
||||
{"latest behind", Bookmark{LastChapterNum: 45, LatestChapterNum: num(44)}, false},
|
||||
{"latest unknown", Bookmark{LastChapterNum: 45}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.b.HasNewChapter(); got != tc.want {
|
||||
t.Fatalf("HasNewChapter() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookmarkContinueURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
b Bookmark
|
||||
want string
|
||||
}{
|
||||
{"chapter url present", Bookmark{LastChapterURL: "/ch/45", SeriesURL: "/series"}, "/ch/45"},
|
||||
{"falls back to series", Bookmark{SeriesURL: "/series"}, "/series"},
|
||||
{"both empty", Bookmark{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.b.ContinueURL(); got != tc.want {
|
||||
t.Fatalf("ContinueURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readLatestCheckedAt reads the column directly. It is deliberately absent from
|
||||
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
|
||||
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
||||
t.Helper()
|
||||
var ts int64
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT latest_checked_at FROM bookmarks WHERE key = ?`, key).Scan(&ts); err != nil {
|
||||
t.Fatalf("read latest_checked_at %q: %v", key, err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// seedForCheck inserts a bookmark and forces its latest_checked_at.
|
||||
func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64) {
|
||||
t.Helper()
|
||||
if _, err := s.Upsert(Bookmark{
|
||||
Key: key,
|
||||
Site: "asura",
|
||||
SeriesID: key,
|
||||
SeriesURL: seriesURL,
|
||||
UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %q: %v", key, err)
|
||||
}
|
||||
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
||||
t.Fatalf("seed mark %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueForLatestCheck(t *testing.T) {
|
||||
const hour = int64(3600_000)
|
||||
now := 10 * hour
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
checkedAt int64
|
||||
seriesURL string
|
||||
wantDue bool
|
||||
}{
|
||||
{"never checked", 0, "https://asurascans.com/comics/a", true},
|
||||
{"checked 30m ago", now - hour/2, "https://asurascans.com/comics/a", false},
|
||||
{"checked exactly at cutoff", now - hour, "https://asurascans.com/comics/a", true},
|
||||
{"checked 2h ago", now - 2*hour, "https://asurascans.com/comics/a", true},
|
||||
{"no series url", 0, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:x", tt.seriesURL, tt.checkedAt)
|
||||
|
||||
due, err := s.DueForLatestCheck(now-hour, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if got := len(due) == 1; got != tt.wantDue {
|
||||
t.Fatalf("due=%v, want %v (got %d rows)", got, tt.wantDue, len(due))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueForLatestCheckOldestFirstAndLimited(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
// Insert newest-checked first so a correct ORDER BY has to reverse it.
|
||||
seedForCheck(t, s, "asura:c", "https://asurascans.com/comics/c", 300)
|
||||
seedForCheck(t, s, "asura:b", "https://asurascans.com/comics/b", 200)
|
||||
seedForCheck(t, s, "asura:a", "https://asurascans.com/comics/a", 100)
|
||||
|
||||
due, err := s.DueForLatestCheck(1000, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 2 {
|
||||
t.Fatalf("got %d rows, want 2 (limit)", len(due))
|
||||
}
|
||||
if due[0].Key != "asura:a" || due[1].Key != "asura:b" {
|
||||
t.Fatalf("got %q,%q; want asura:a,asura:b (oldest first)", due[0].Key, due[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkLatestChecked(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 0)
|
||||
|
||||
if err := s.MarkLatestChecked("asura:x", 4242); err != nil {
|
||||
t.Fatalf("MarkLatestChecked: %v", err)
|
||||
}
|
||||
if got := readLatestCheckedAt(t, s, "asura:x"); got != 4242 {
|
||||
t.Fatalf("latest_checked_at = %d, want 4242", got)
|
||||
}
|
||||
// A missing key is not an error: the row may have been deleted mid-fetch.
|
||||
if err := s.MarkLatestChecked("asura:gone", 1); err != nil {
|
||||
t.Fatalf("MarkLatestChecked on missing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert must not touch latest_checked_at. If the column ever migrates into
|
||||
// bookmarkColumns, this fails and the cooldown is silently dead.
|
||||
func TestUpsertPreservesLatestCheckedAt(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 999)
|
||||
|
||||
b, ok, err := s.Get("asura:x")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: %v ok=%v", err, ok)
|
||||
}
|
||||
b.Title = "changed"
|
||||
if _, err := s.Upsert(b); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if got := readLatestCheckedAt(t, s, "asura:x"); got != 999 {
|
||||
t.Fatalf("latest_checked_at = %d after Upsert, want 999", got)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateColumns must be able to bring a database created before this column up
|
||||
// to date, not just create it fresh.
|
||||
func TestMigrateAddsLatestCheckedAt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
|
||||
old, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
// A pre-latest_checked_at table, matching the schema as it shipped before.
|
||||
if _, err := old.Exec(`CREATE TABLE bookmarks (
|
||||
key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL,
|
||||
title TEXT, series_url TEXT, cover TEXT,
|
||||
last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter_num REAL,
|
||||
updated_at INTEGER NOT NULL)`); err != nil {
|
||||
t.Fatalf("create old table: %v", err)
|
||||
}
|
||||
if _, err := old.Exec(
|
||||
`INSERT INTO bookmarks (key, site, series_id, series_url, updated_at)
|
||||
VALUES ('asura:x', 'asura', 'x', 'https://asurascans.com/comics/x', 5)`); err != nil {
|
||||
t.Fatalf("seed old row: %v", err)
|
||||
}
|
||||
if err := old.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore on pre-existing db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
|
||||
// The migrated row must default to 0 (never checked) and so be due.
|
||||
if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 {
|
||||
t.Fatalf("migrated latest_checked_at = %d, want 0", got)
|
||||
}
|
||||
due, err := s.DueForLatestCheck(1000, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("got %d due rows after migration, want 1", len(due))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
stored, err := store.Upsert(Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty status means "no opinion" — a userscript build that predates the
|
||||
// column must not reset the bucket on every progress PUT.
|
||||
func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if _, err := store.Upsert(base); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
base.Status = ""
|
||||
base.LastChapterNum = 12
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the poller's read-modify-write in latest.go: Get the current row,
|
||||
// mutate only the latest-chapter fields, and Upsert the whole struct back.
|
||||
// cur.Status comes from Get (never empty — see scanBookmark), so it must
|
||||
// round-trip through Upsert unchanged rather than being reset.
|
||||
func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if _, err := store.Upsert(base); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
cur, found, err := store.Get(base.Key)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Get: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
num := 7.0
|
||||
cur.LatestChapter = "7"
|
||||
cur.LatestChapterNum = &num
|
||||
cur.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
stored, err := store.Upsert(cur)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if _, err := store.Upsert(base); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
base.Status = StatusReading
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != StatusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||
}
|
||||
}
|
||||
|
||||
// A status change is not reading progress, so the list must not reorder.
|
||||
func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
LastChapter: "45", LastChapterNum: 45,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
first, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
base.Status = StatusArchived
|
||||
base.UpdatedAt = first.UpdatedAt + 60_000
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.UpdatedAt != first.UpdatedAt {
|
||||
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// A database written before the column existed must gain it, with every
|
||||
// pre-existing row landing in the reading bucket.
|
||||
func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE bookmarks (
|
||||
key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL,
|
||||
title TEXT, series_url TEXT, cover TEXT,
|
||||
last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT,
|
||||
updated_at INTEGER NOT NULL);
|
||||
INSERT INTO bookmarks (key, site, series_id, updated_at)
|
||||
VALUES ('asura:old', 'asura', 'old', 1)`); err != nil {
|
||||
t.Fatalf("seed legacy: %v", err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
store, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
b, ok, err := store.Get("asura:old")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if b.Status != StatusReading {
|
||||
t.Fatalf("Status = %q, want %q", b.Status, StatusReading)
|
||||
}
|
||||
}
|
||||
|
||||
// Archiving is the reason to keep polling — the point is to come back to a
|
||||
// series that has moved on. A finished series has nothing left to publish.
|
||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
for _, tc := range []struct{ key, status string }{
|
||||
{"asura:reading", StatusReading},
|
||||
{"asura:archived", StatusArchived},
|
||||
{"asura:finished", StatusFinished},
|
||||
} {
|
||||
if _, err := store.Upsert(Bookmark{
|
||||
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
||||
SeriesURL: "https://asurascans.com/comics/" + tc.key,
|
||||
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", tc.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
due, err := store.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, b := range due {
|
||||
got[b.Key] = true
|
||||
}
|
||||
if !got["asura:reading"] || !got["asura:archived"] {
|
||||
t.Fatalf("due = %v, want reading and archived present", got)
|
||||
}
|
||||
if got["asura:finished"] {
|
||||
t.Fatal("due included a finished series")
|
||||
}
|
||||
}
|
||||
|
||||
// Asura slugs used to include the site build hash; rows keyed with it must
|
||||
// be rewritten to the stable ID on open, merging hash-generations of the
|
||||
// same series into the newest row.
|
||||
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
||||
|
||||
store, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
seed := []Bookmark{
|
||||
{Key: "asura:swordmasters-youngest-son-f886a8af", Site: "asura",
|
||||
SeriesID: "swordmasters-youngest-son-f886a8af", Title: "Old gen",
|
||||
LastChapterNum: 50, UpdatedAt: 100},
|
||||
{Key: "asura:swordmasters-youngest-son-059befe1", Site: "asura",
|
||||
SeriesID: "swordmasters-youngest-son-059befe1", Title: "Re-bookmarked",
|
||||
LastChapterNum: 60, UpdatedAt: 200},
|
||||
{Key: "asura:overgeared-059befe1", Site: "asura",
|
||||
SeriesID: "overgeared-059befe1", Title: "Single gen", UpdatedAt: 150},
|
||||
// Hash-like suffix on another site must be left alone.
|
||||
{Key: "demonic:x-deadbeef", Site: "demonic",
|
||||
SeriesID: "x-deadbeef", Title: "Not asura", UpdatedAt: 300},
|
||||
}
|
||||
for _, b := range seed {
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { reopened.Close() })
|
||||
|
||||
list, err := reopened.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
byKey := map[string]Bookmark{}
|
||||
for _, b := range list {
|
||||
byKey[b.Key] = b
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("want 3 rows after merge, got %d: %+v", len(list), list)
|
||||
}
|
||||
merged, ok := byKey["asura:swordmasters-youngest-son"]
|
||||
if !ok {
|
||||
t.Fatalf("merged key missing: %+v", byKey)
|
||||
}
|
||||
// Newest row wins the merge.
|
||||
if merged.Title != "Re-bookmarked" || merged.LastChapterNum != 60 || merged.UpdatedAt != 200 {
|
||||
t.Fatalf("merge kept wrong row: %+v", merged)
|
||||
}
|
||||
if merged.SeriesID != "swordmasters-youngest-son" {
|
||||
t.Fatalf("series_id not stripped: %q", merged.SeriesID)
|
||||
}
|
||||
if _, ok := byKey["asura:overgeared-059befe1"]; ok {
|
||||
t.Fatal("single-generation hashed key not rewritten")
|
||||
}
|
||||
if _, ok := byKey["asura:overgeared"]; !ok {
|
||||
t.Fatal("single-generation row missing under stripped key")
|
||||
}
|
||||
if _, ok := byKey["demonic:x-deadbeef"]; !ok {
|
||||
t.Fatal("non-asura row touched")
|
||||
}
|
||||
|
||||
// Idempotent: a third open changes nothing.
|
||||
third, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("third open: %v", err)
|
||||
}
|
||||
third.Close()
|
||||
}
|
||||
|
||||
// A hashed row and a pre-existing hashless row of the same series collide on
|
||||
// the stripped key. The winner rewrite must happen only after the loser is
|
||||
// gone, or the UPDATE hits a primary-key collision and OpenStore fails.
|
||||
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
||||
|
||||
store, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
seed := []Bookmark{
|
||||
{Key: "asura:overgeared", Site: "asura", SeriesID: "overgeared",
|
||||
Title: "Hashless", LastChapterNum: 10, UpdatedAt: 100},
|
||||
{Key: "asura:overgeared-059befe1", Site: "asura",
|
||||
SeriesID: "overgeared-059befe1", Title: "Hashed newer",
|
||||
LastChapterNum: 20, UpdatedAt: 200},
|
||||
}
|
||||
for _, b := range seed {
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { reopened.Close() })
|
||||
|
||||
list, err := reopened.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want 1 row after merge, got %d: %+v", len(list), list)
|
||||
}
|
||||
merged := list[0]
|
||||
if merged.Key != "asura:overgeared" {
|
||||
t.Fatalf("merged key = %q, want asura:overgeared", merged.Key)
|
||||
}
|
||||
if merged.SeriesID != "overgeared" {
|
||||
t.Fatalf("series_id = %q, want overgeared", merged.SeriesID)
|
||||
}
|
||||
if merged.Title != "Hashed newer" || merged.LastChapterNum != 20 || merged.UpdatedAt != 200 {
|
||||
t.Fatalf("merge kept wrong row: %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayChapter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
num float64
|
||||
want string
|
||||
}{
|
||||
{"userscript label", "Chapter 250", 250, "Ch 250"},
|
||||
{"abbreviated with dot", "Ch. 12", 12, "Ch 12"},
|
||||
{"lowercase abbreviation", "ch 3", 3, "Ch 3"},
|
||||
{"bare number from a manual edit", "45.5", 45.5, "Ch 45.5"},
|
||||
{"empty falls back to the number", "", 7, "Ch 7"},
|
||||
{"empty and zero", "", 0, "Ch 0"},
|
||||
{"label with no number passes through", "Prologue", 0, "Prologue"},
|
||||
{"title-like label keeps its words", "Chapter of the Sun", 0, "of the Sun"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := displayChapter(tc.raw, tc.num); got != tc.want {
|
||||
t.Errorf("displayChapter(%q, %v) = %q, want %q", tc.raw, tc.num, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
b := Bookmark{LastChapter: "Chapter 9", LastChapterNum: 9}
|
||||
if got := b.DisplayChapter(); got != "Ch 9" {
|
||||
t.Errorf("DisplayChapter() = %q, want %q", got, "Ch 9")
|
||||
}
|
||||
if got := b.DisplayLatest(); got != "Ch 0" {
|
||||
t.Errorf("DisplayLatest() with nothing captured = %q, want %q", got, "Ch 0")
|
||||
}
|
||||
num := 11.0
|
||||
b.LatestChapter, b.LatestChapterNum = "Chapter 11", &num
|
||||
if got := b.DisplayLatest(); got != "Ch 11" {
|
||||
t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user