package store import ( "os" "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 // 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 = $1`, 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) } } 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: 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") } } 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) } }