package main import ( "bytes" "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" ) const testToken = "s3cret-token" func testConfig() Config { return Config{ Token: testToken, AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, Port: "8080", } } func newTestServer(t *testing.T) http.Handler { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") store, err := OpenStore(dbPath) if err != nil { t.Fatalf("OpenStore: %v", err) } t.Cleanup(func() { store.Close() }) return newRouter(store, testConfig()) } func newTestStore(t *testing.T) *Store { t.Helper() store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("OpenStore: %v", err) } t.Cleanup(func() { store.Close() }) return store } func auth(req *http.Request) *http.Request { req.Header.Set("Authorization", "Bearer "+testToken) return req } func TestHealthzNoAuth(t *testing.T) { srv := newTestServer(t) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rr.Code != http.StatusOK { t.Fatalf("healthz status = %d, want 200", rr.Code) } if rr.Body.String() != "ok" { t.Fatalf("healthz body = %q, want ok", rr.Body.String()) } } func TestAuthRequired(t *testing.T) { srv := newTestServer(t) cases := []struct { name string header string }{ {"no header", ""}, {"bad token", "Bearer wrong"}, {"not bearer", "Basic " + testToken}, {"empty bearer", "Bearer "}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) if tc.header != "" { req.Header.Set("Authorization", tc.header) } rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rr.Code) } }) } } func TestAuthAccepted(t *testing.T) { srv := newTestServer(t) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if got := rr.Body.String(); got != "[]\n" { t.Fatalf("empty list body = %q, want []", got) } } func TestCORSPreflight(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil) req.Header.Set("Origin", "https://asuracomic.net") req.Header.Set("Access-Control-Request-Method", "PUT") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusNoContent { t.Fatalf("preflight status = %d, want 204", rr.Code) } if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" { t.Fatalf("Allow-Origin = %q, want reflected origin", got) } if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Fatal("Allow-Methods missing") } if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" { t.Fatal("Allow-Headers missing") } } func TestCORSDisallowedOrigin(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil) req.Header.Set("Origin", "https://evil.example") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got) } } func TestBookmarkRoundTrip(t *testing.T) { srv := newTestServer(t) key := "asura:solo-leveling-123" in := Bookmark{ Title: "Solo Leveling", SeriesURL: "https://asuracomic.net/series/solo-leveling-123", Cover: "https://asuracomic.net/cover.jpg", LastChapter: "Chapter 10", LastChapterNum: 10, LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10", } body, _ := json.Marshal(in) // PUT rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT status = %d, want 200", rr.Code) } var stored Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil { t.Fatalf("decode PUT response: %v", err) } if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" { t.Fatalf("derived fields wrong: %+v", stored) } if stored.UpdatedAt == 0 { t.Fatal("server did not set updated_at") } // GET rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) var list []Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 { t.Fatalf("GET list wrong: %+v", list) } // PUT again (upsert, progress advance) in.LastChapter, in.LastChapterNum = "Chapter 11", 11 body, _ = json.Marshal(in) rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("second PUT status = %d", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) json.Unmarshal(rr.Body.Bytes(), &list) if len(list) != 1 || list[0].LastChapterNum != 11 { t.Fatalf("upsert did not update in place: %+v", list) } // DELETE rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil))) if rr.Code != http.StatusNoContent { t.Fatalf("DELETE status = %d, want 204", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) json.Unmarshal(rr.Body.Bytes(), &list) if len(list) != 0 { t.Fatalf("after delete list = %+v, want empty", list) } } // putBookmark PUTs b at key and returns the bookmark the server echoes back, // which is the row as actually stored (not the request payload). func putBookmark(t *testing.T, srv http.Handler, key string, b Bookmark) Bookmark { t.Helper() body, _ := json.Marshal(b) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String()) } var out Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatalf("decode PUT response: %v", err) } return out } func getBookmarks(t *testing.T, srv http.Handler) []Bookmark { t.Helper() rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) if rr.Code != http.StatusOK { t.Fatalf("GET status = %d", rr.Code) } var list []Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } return list } func floatPtr(f float64) *float64 { return &f } // updated_at drives list ordering, so it must move only on a real progress // advance — never on a favorite toggle or a latest-chapter capture. func TestUpsertConditionalUpdatedAt(t *testing.T) { cases := []struct { name string mutate func(Bookmark) Bookmark wantBumped bool }{ { name: "unchanged progress", mutate: func(b Bookmark) Bookmark { return b }, wantBumped: false, }, { name: "changed progress", mutate: func(b Bookmark) Bookmark { b.LastChapter, b.LastChapterNum = "Chapter 11", 11 return b }, wantBumped: true, }, { name: "favorite only", mutate: func(b Bookmark) Bookmark { b.Favorite = true return b }, wantBumped: false, }, { name: "latest chapter only", mutate: func(b Bookmark) Bookmark { b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15) return b }, wantBumped: false, }, { name: "unrelated metadata only", mutate: func(b Bookmark) Bookmark { b.Title, b.Cover = "Renamed", "https://example.test/new.jpg" return b }, wantBumped: false, }, } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := newTestServer(t) key := fmt.Sprintf("asura:cond-%d", i) first := putBookmark(t, srv, key, Bookmark{ Title: "Test", LastChapter: "Chapter 10", LastChapterNum: 10, }) if first.UpdatedAt == 0 { t.Fatal("new bookmark did not get updated_at set") } // Guarantee a later wall-clock ms so a real bump is observable. time.Sleep(2 * time.Millisecond) second := putBookmark(t, srv, key, tc.mutate(first)) if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt { t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt) } if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt { t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt) } // The PUT response must match what a subsequent GET reports. list := getBookmarks(t, srv) if len(list) != 1 { t.Fatalf("list = %+v, want 1 item", list) } if list[0].UpdatedAt != second.UpdatedAt { t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt) } }) } } func TestFavoriteRoundTrip(t *testing.T) { srv := newTestServer(t) key := "demonic:some-series" stored := putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: true}) if !stored.Favorite { t.Fatalf("PUT response favorite = false, want true") } list := getBookmarks(t, srv) if len(list) != 1 || !list[0].Favorite { t.Fatalf("favorite did not round-trip: %+v", list) } // Unfavoriting must persist too (guards against a write that only ever ORs in true). stored = putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: false}) if stored.Favorite { t.Fatal("PUT response favorite = true after unfavorite") } list = getBookmarks(t, srv) if len(list) != 1 || list[0].Favorite { t.Fatalf("unfavorite did not round-trip: %+v", list) } } func TestLatestChapterNullable(t *testing.T) { srv := newTestServer(t) key := "asura:latest-test" // Never captured: latest_chapter_num must serialize as JSON null. body, _ := json.Marshal(Bookmark{Title: "No latest yet"}) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT status = %d", rr.Code) } if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) { t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String()) } list := getBookmarks(t, srv) if len(list) != 1 || list[0].LatestChapterNum != nil { t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum) } // Once captured it round-trips as a value. stored := putBookmark(t, srv, key, Bookmark{ Title: "No latest yet", LatestChapter: "Chapter 162", LatestChapterNum: floatPtr(162), }) if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 { t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum) } list = getBookmarks(t, srv) if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 { t.Fatalf("latest chapter did not round-trip: %+v", list) } if list[0].LatestChapter != "Chapter 162" { t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162") } } // The deployed database predates favorite/latest_chapter*, and CREATE TABLE // IF NOT EXISTS will not add them — OpenStore must migrate in place. 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 := OpenStore(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 := OpenStore(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) } }) } } func TestLoadConfigWebPassword(t *testing.T) { t.Setenv("API_TOKEN", "token-abc") t.Setenv("WEB_PASSWORD", "hunter2") if got := loadConfig().WebPassword; got != "hunter2" { t.Fatalf("WebPassword = %q, want hunter2", got) } t.Setenv("WEB_PASSWORD", "") if got := loadConfig().WebPassword; got != "" { t.Fatalf("WebPassword = %q with the variable unset, want empty", got) } } // 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 := OpenStore(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)) } } // A userscript PUT body has no latest_checked_at field. If the column is ever // moved into bookmarkColumns, this test catches it: the PUT would reset the // cooldown and the poller would re-fetch that series on every single tick. func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") store, err := OpenStore(dbPath) if err != nil { t.Fatalf("OpenStore: %v", err) } t.Cleanup(func() { store.Close() }) srv := newRouter(store, testConfig()) seedForCheck(t, store, "asura:x", "https://asurascans.com/comics/x", 777) // Exactly what the userscript sends: no latest_checked_at key at all. body := `{"key":"asura:x","site":"asura","series_id":"x", "series_url":"https://asurascans.com/comics/x", "last_chapter":"Chapter 5","last_chapter_num":5}` req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+testToken) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) } if got := readLatestCheckedAt(t, store, "asura:x"); got != 777 { t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", 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) } } // 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 := OpenStore(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") } }