08749df050
Swap modernc.org/sqlite for jackc/pgx/v5 with no observable change: same endpoints, same wire format, same updated_at ordering rule. The schema now comes from numbered SQL embedded in the binary and applied on startup, one transaction each, recorded in schema_migrations. That replaces two pieces of SQLite-era machinery, both deleted rather than ported: the column probing (Postgres has ADD COLUMN IF NOT EXISTS, and there is no legacy database left to probe) and the Asura key rewrite, which has run clean on every start for months now that the userscripts strip build hashes before writing. Its regexp survives as latest.asuraBuildHash, where the poller still needs it to scope chapter links to a series whose slug carries a rotating hash. Types get real: favorite is a boolean, chapter numbers double precision, timestamps stay unix-ms bigint. SQLite's null-safe IS NOT becomes IS DISTINCT FROM, which is what implements the rule that only reading progress reorders a list. Inside COALESCE/NULLIF the status and kind parameters need an explicit ::text -- there is no target column to infer from and Postgres refuses to guess. Tests lose their free t.TempDir() database, so Docker is now a hard prerequisite for `go test ./...`: internal/pgtest starts one postgres:17-alpine per test binary and hands each test a database of its own. Also lands CONTEXT.md and the four ADRs written while scoping #18. BREAKING CHANGE: DB_PATH is retired for DATABASE_URL, which is required and has no default. Compose gains a postgres service on an internal network with its own volume; POSTGRES_PASSWORD joins .env. The old bookmarks-data volume is deliberately left undeclared so `docker compose down -v` cannot take the pre-migration database with it. main is not deployable until #25 and #26 land. Closes #20 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
479 lines
14 KiB
Go
479 lines
14 KiB
Go
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)
|
|
}
|
|
}
|