984965ed9f
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
754 lines
24 KiB
Go
754 lines
24 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/pgtest"
|
|
)
|
|
|
|
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
store, err := Open(pgtest.URL(t))
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
return store
|
|
}
|
|
|
|
// The migration runner runs on every start, so a second Open against a
|
|
// database it already built must be a no-op rather than a duplicate-table
|
|
// error, and must leave the rows alone.
|
|
func TestOpenIsIdempotent(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
first, err := Open(url)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if _, err := first.Upsert(Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
first.Close()
|
|
|
|
second, err := Open(url)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
t.Cleanup(func() { second.Close() })
|
|
|
|
list, err := second.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].Key != "asura:solo" {
|
|
t.Fatalf("rows after reopen = %+v, want only the seeded one", list)
|
|
}
|
|
}
|
|
|
|
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
|
|
// Series (see Store.MarkLatestChecked), so tests cannot assert on it any other
|
|
// way. The key splits the same way the API handler derives site/series_id.
|
|
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
var ts int64
|
|
if err := s.db.QueryRow(
|
|
`SELECT latest_checked_at FROM series WHERE site = $1 AND series_id = $2`,
|
|
site, seriesID).Scan(&ts); err != nil {
|
|
t.Fatalf("read latest_checked_at %q: %v", key, err)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
// seedForCheck inserts a bookmark (and with it its series) and forces the
|
|
// series' latest_checked_at.
|
|
func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64) {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
if _, err := s.Upsert(Bookmark{
|
|
Key: key,
|
|
Site: site,
|
|
SeriesID: seriesID,
|
|
SeriesURL: seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed %q: %v", key, err)
|
|
}
|
|
if err := s.MarkLatestChecked(site, seriesID, 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 series is not an error: its bookmarks may have been deleted
|
|
// mid-fetch.
|
|
if err := s.MarkLatestChecked("asura", "gone", 1); err != nil {
|
|
t.Fatalf("MarkLatestChecked on missing series: %v", err)
|
|
}
|
|
}
|
|
|
|
// Upsert must not touch latest_checked_at. If the column ever migrates into
|
|
// the client-visible write path, 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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: strings.TrimPrefix(tc.key, "asura:"),
|
|
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 _, sr := range due {
|
|
got[sr.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")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestUpsertKindDefaultsToManga(t *testing.T) {
|
|
store := newTestStore(t)
|
|
got, err := store.Upsert(Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindManga {
|
|
t.Fatalf("Kind = %q, want %q", got.Kind, KindManga)
|
|
}
|
|
}
|
|
|
|
func TestUpsertKindRoundTrips(t *testing.T) {
|
|
store := newTestStore(t)
|
|
got, err := store.Upsert(Bookmark{
|
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
|
SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindNovel {
|
|
t.Fatalf("Kind = %q, want %q", got.Kind, KindNovel)
|
|
}
|
|
}
|
|
|
|
// The real hazard: a client that predates the column sends no kind at all. That
|
|
// must keep the stored library, not silently demote a novel to manga.
|
|
func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(Bookmark{
|
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
|
SeriesID: "a-will-eternal", Kind: KindNovel, LastChapterNum: 10, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
got, err := store.Upsert(Bookmark{
|
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
|
SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindNovel {
|
|
t.Fatalf("Kind = %q, want %q — an empty kind must not reset the library", got.Kind, KindNovel)
|
|
}
|
|
if got.LastChapterNum != 11 {
|
|
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
|
|
}
|
|
}
|
|
|
|
// The 0002 backfill must survive a database that already ran 0001 with real
|
|
// rows: one series row per distinct (site, series_id) carrying the moved
|
|
// columns, and the bookmark keeping the rest. That is the upgrade path for
|
|
// every deployed database, so it is exercised rather than trusted.
|
|
func TestMigration0002BackfillsExistingBookmarks(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// Run only 0001, as a database created before this change would have.
|
|
// migrate() normally creates the version table first; do the same here.
|
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version bigint PRIMARY KEY,
|
|
applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
|
t.Fatalf("create version table: %v", err)
|
|
}
|
|
body, err := migrations.ReadFile("migrations/0001_bookmarks.sql")
|
|
if err != nil {
|
|
t.Fatalf("read 0001: %v", err)
|
|
}
|
|
if err := applyMigration(db, 1, string(body)); err != nil {
|
|
t.Fatalf("apply 0001: %v", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
INSERT INTO bookmarks (key, site, series_id, title, series_url, cover,
|
|
last_chapter, last_chapter_num, last_chapter_url, favorite, latest_chapter,
|
|
latest_chapter_num, latest_checked_at, status, kind, updated_at)
|
|
VALUES ('asura:solo', 'asura', 'solo', 'Solo Leveling',
|
|
'https://asurascans.com/comics/solo', 'https://asurascans.com/covers/solo.jpg',
|
|
'Chapter 10', 10, 'https://asurascans.com/comics/solo/ch/10', true,
|
|
'Chapter 11', 11, 123456, 'reading', 'manga', 1000)`); err != nil {
|
|
t.Fatalf("seed legacy row: %v", err)
|
|
}
|
|
|
|
// Bring it current: 0002 must backfill the series row, not lose data.
|
|
if err := migrate(db); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
var (
|
|
title string
|
|
checked int64
|
|
fav bool
|
|
lastNum float64
|
|
)
|
|
if err := db.QueryRow(`SELECT title, latest_checked_at FROM series
|
|
WHERE site = 'asura' AND series_id = 'solo'`).Scan(&title, &checked); err != nil {
|
|
t.Fatalf("series row missing after migration: %v", err)
|
|
}
|
|
if title != "Solo Leveling" || checked != 123456 {
|
|
t.Fatalf("series = (%q, %d), want backfilled title and latest_checked_at", title, checked)
|
|
}
|
|
if err := db.QueryRow(`SELECT favorite, last_chapter_num FROM bookmarks
|
|
WHERE key = 'asura:solo'`).Scan(&fav, &lastNum); err != nil {
|
|
t.Fatalf("bookmark row missing after migration: %v", err)
|
|
}
|
|
if !fav || lastNum != 10 {
|
|
t.Fatalf("bookmark = (%v, %v), want favorite and progress kept", fav, lastNum)
|
|
}
|
|
|
|
// The migrated database opens as a normal store.
|
|
st, err := Open(url)
|
|
if err != nil {
|
|
t.Fatalf("Open after migrate: %v", err)
|
|
}
|
|
defer st.Close()
|
|
}
|
|
|
|
// readSeries reads the series row directly, for asserting on what Upsert
|
|
// actually stored rather than what the joined Bookmark reports.
|
|
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
|
t.Helper()
|
|
sr, err := scanSeries(s.db.QueryRow(
|
|
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
|
|
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
|
if err != nil {
|
|
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
|
}
|
|
return sr
|
|
}
|
|
|
|
// The first PUT for a series creates its row from the client's title, cover
|
|
// and URL — there is no other source for them (ADR-0003).
|
|
func TestUpsertCreatesSeriesFromClient(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
|
Cover: "https://asurascans.com/covers/solo.jpg", Kind: KindManga,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
|
|
sr := readSeries(t, store, "asura", "solo")
|
|
if sr.Title != "Solo Leveling" || sr.SeriesURL != "https://asurascans.com/comics/solo" ||
|
|
sr.Cover != "https://asurascans.com/covers/solo.jpg" {
|
|
t.Fatalf("series = %+v, want client title/url/cover stored", sr)
|
|
}
|
|
}
|
|
|
|
// A PUT naming an existing series must not overwrite its title, cover or URL:
|
|
// the row is shared, and those values are scraped page content (ADR-0003).
|
|
func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
|
Cover: "https://asurascans.com/covers/solo.jpg", LastChapterNum: 10,
|
|
UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
// Same series, hostile/compromised values, real progress advance.
|
|
base.Title = "Scraped Rename"
|
|
base.SeriesURL = "https://evil.example/solo"
|
|
base.Cover = "https://evil.example/solo.jpg"
|
|
base.LastChapterNum = 11
|
|
got, err := store.Upsert(base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
|
|
got.Cover != "https://asurascans.com/covers/solo.jpg" {
|
|
t.Fatalf("stored = %+v, want original title/url/cover kept", got)
|
|
}
|
|
if got.LastChapterNum != 11 {
|
|
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
|
|
}
|
|
}
|
|
|
|
// Kind and latest-chapter are last-write-wins even on an existing series: the
|
|
// poller and the userscript both report the latest chapter, and kind is only
|
|
// known to whichever client created the row.
|
|
func TestUpsertExistingSeriesAcceptsKindAndLatest(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
|
|
UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
num := 12.0
|
|
base.Kind = KindNovel
|
|
base.LatestChapter = "Chapter 12"
|
|
base.LatestChapterNum = &num
|
|
got, err := store.Upsert(base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindNovel || got.LatestChapter != "Chapter 12" ||
|
|
got.LatestChapterNum == nil || *got.LatestChapterNum != 12 {
|
|
t.Fatalf("stored = %+v, want kind and latest chapter updated", got)
|
|
}
|
|
}
|
|
|
|
// Deleting the last bookmark must leave the series row behind, so a later
|
|
// re-bookmark shows title and cover immediately instead of waiting for a poll.
|
|
func TestDeleteKeepsSeriesRow(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", Cover: "https://asurascans.com/covers/solo.jpg",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := store.Delete("asura:solo"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
sr := readSeries(t, store, "asura", "solo")
|
|
if sr.Title != "Solo Leveling" {
|
|
t.Fatalf("series = %+v, want it kept after the last bookmark is deleted", sr)
|
|
}
|
|
|
|
// Re-bookmark with nothing but progress: the stored title/cover come back.
|
|
stored, err := store.Upsert(Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
LastChapterNum: 5, UpdatedAt: 2000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("re-upsert: %v", err)
|
|
}
|
|
if stored.Title != "Solo Leveling" || stored.Cover != "https://asurascans.com/covers/solo.jpg" {
|
|
t.Fatalf("re-bookmark = %+v, want title/cover from the surviving series row", stored)
|
|
}
|
|
}
|
|
|
|
// seedSecondReader inserts an extra bookmark on an existing series. Today the
|
|
// bookmark key is <site>:<series_id>, so two bookmarks can share a series only
|
|
// once keys stop being derived from the series identity (issue #22); the due
|
|
// queue's reader-count ordering must already be right for that world.
|
|
func seedSecondReader(t *testing.T, s *Store, key, site, seriesID string, updatedAt int64) {
|
|
t.Helper()
|
|
if _, err := s.Upsert(Bookmark{
|
|
Key: key, Site: site, SeriesID: seriesID, UpdatedAt: updatedAt,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader %q: %v", key, err)
|
|
}
|
|
}
|
|
|
|
// The whole point of the split: a shared series is due once, ordered ahead of
|
|
// single-reader series by how many bookmarks reference it.
|
|
func TestDueForLatestCheckOrdersByReaderCountThenAge(t *testing.T) {
|
|
s := newTestStore(t)
|
|
// "pop" has two bookmarks but was checked most recently; "solo" has one and
|
|
// was checked long ago. Reader count must win over age.
|
|
seedForCheck(t, s, "asura:pop", "https://asurascans.com/comics/pop", 900)
|
|
seedSecondReader(t, s, "asura:pop:2", "asura", "pop", 1001)
|
|
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 100)
|
|
|
|
due, err := s.DueForLatestCheck(1000, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 2 {
|
|
t.Fatalf("due = %d rows, want 2", len(due))
|
|
}
|
|
if due[0].Key() != "asura:pop" || due[1].Key() != "asura:solo" {
|
|
t.Fatalf("due order = %q, %q; want asura:pop (2 readers), asura:solo (1)",
|
|
due[0].Key(), due[1].Key())
|
|
}
|
|
}
|
|
|
|
// A series with no bookmarks must never appear in the due queue, and nothing
|
|
// in the store ever deletes it (see TestDeleteKeepsSeriesRow).
|
|
func TestDueForLatestCheckExcludesOrphanSeries(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:kept", "https://asurascans.com/comics/kept", 0)
|
|
if _, err := s.db.Exec(`
|
|
INSERT INTO series (site, series_id, title, series_url, cover, kind,
|
|
latest_chapter, latest_chapter_num, latest_checked_at)
|
|
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
|
|
'', 'manga', '', NULL, 0)`); err != nil {
|
|
t.Fatalf("seed orphan series: %v", err)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck(1000, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 1 || due[0].Key() != "asura:kept" {
|
|
t.Fatalf("due = %+v, want only the bookmarked series", due)
|
|
}
|
|
var n int
|
|
if err := s.db.QueryRow(
|
|
`SELECT count(*) FROM series WHERE site = 'asura' AND series_id = 'orphan'`).Scan(&n); err != nil {
|
|
t.Fatalf("count orphan series: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("orphan series count = %d, want 1 (never deleted)", n)
|
|
}
|
|
}
|