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,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