62772e1eaa
Adds a background goroutine to the backend that re-checks each bookmarked series' newest published chapter on its own schedule, so `latest_chapter` stays fresh even when the manga sites are never opened in a browser.
This is a *second, parallel* signal, not a replacement: the userscript keeps its own `maybeCaptureLatestOnSeriesPage` / `backgroundRefreshLatest` logic, unchanged. `userscript/manga-bookmark.user.js` is byte-identical to `main`.
## How it works
One ticker goroutine in the same binary. Each wake it asks SQLite for bookmarks whose `latest_checked_at` has aged past a per-bookmark cooldown, fetches those series pages through a Chrome-fingerprinted HTTP client, extracts the max chapter number with a per-site regex, and writes it back through `Store.Get` + `Store.Upsert`. Every failure path logs and moves on.
Two independent clocks:
- **cooldown** — how long one bookmark rests between checks, enforced by the `WHERE` clause in `Store.DueForLatestCheck`, not by a timer.
- **interval** — how often the goroutine wakes and looks.
Shortening the interval therefore cannot shorten anyone's cooldown; it only makes the poller wake and find nothing due more often.
The row is stamped **before** the fetch, so an error, a timeout, or a shutdown mid-request still consumes the cooldown — a renamed or challenged series waits out a full cooldown instead of being retried every tick.
## Design decisions worth reviewing
**`latest_checked_at` is deliberately absent from the `Bookmark` struct and from `bookmarkColumns`.** `PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes every column it knows about, so a userscript PUT — which has no idea this field exists — would write a zero and reset the cooldown, making the poller re-fetch that series on every tick for as long as the user kept reading it. Two tests guard this: `TestUpsertPreservesLatestCheckedAt` and `TestPutDoesNotClobberLatestCheckedAt`, the latter driving a real router PUT with a userscript-shaped body.
**`updated_at` never moves on a latest-chapter bump.** All chapter writes go through `Store.Get` + `Store.Upsert`, so the existing `CASE` keeps the stored timestamp when only `latest_chapter_num` changes and the bookmark list does not reorder. `TestRunOnceDoesNotReorderList` asserts both the timestamp and the `List()` head position.
**Fetches use `bogdanfinn/tls-client` with a Chrome profile.** Plain `net/http` was verified working against both sites on 2026-07-26, so this is not fixing an observed block — it is deliberate defence-in-depth against a future fingerprint-based one. The library is pure Go, so `CGO_ENABLED=0`, the static binary, and the distroless image are all unaffected. It does require the Go floor to move 1.23 → 1.24.
**`checkOne` validates before spending a request.** `series_url` is entirely client-supplied through `PUT /bookmarks/{key}`, so without a guard the poller would issue GETs from the server's own network position to any URL a token holder writes. The check requires a known site and an `https` URL with a non-empty host, and sits *after* the cooldown stamp so an unfetchable row is retried at cooldown pace rather than hot-looping.
## Config
Five new env vars, all with defaults sized for this deployment, all wired through `docker-compose.yml`:
| Variable | Default | Meaning |
| --- | --- | --- |
| `LATEST_CHAPTER_POLL_ENABLED` | `1` | Kill switch |
| `LATEST_CHAPTER_POLL_COOLDOWN` | `1h` | Per series, floored at `15m` |
| `LATEST_CHAPTER_POLL_INTERVAL` | `10m` | How often to wake |
| `LATEST_CHAPTER_POLL_BATCH` | `14` | Series per wake |
| `LATEST_CHAPTER_POLL_STAGGER` | `20s` | Delay between fetches in a batch |
`batch × (cooldown / interval)` = 84 series hold a true cooldown cadence at these defaults. Past that nothing breaks: the cadence stretches uniformly and the oldest-checked-first ordering keeps it fair. Bad values log and fall back rather than failing startup — the poller is an enhancement, and a typo in one of its knobs must not stop bookmark sync.
## Known limitation (accepted, documented)
The poller's `Store.Get` + `Store.Upsert` is not wrapped in a single transaction. If a userscript `PUT` commits in the sub-millisecond window between the two, the poller writes back its stale re-read — reverting that progress and, since the stored `last_chapter_num` now differs, tripping the `updated_at` `CASE` and reordering the list.
Accepted rather than fixed for a single-user deployment: the window is one SELECT wide, the poller only writes when a chapter number actually changed, and the next read self-heals it. The alternative — a transactional read-modify-write — means moving or duplicating the `updated_at` `CASE` that four tests and the whole list-ordering invariant depend on. Recorded in `CLAUDE.md` next to the poller's architecture bullet so it is not a silent trap.
## Testing
- Full suite green, including `-race`; `go vet` clean; `CGO_ENABLED=0` static build and `docker compose build` both pass on the bumped `golang:1.24-alpine`.
- No test touches the network: the `fetcher` interface exists so tests inject a fake, and no test imports `tls-client` or reaches either manga site.
- Extraction is fixture-driven against markup trimmed from real pages (2026-07-26), including a Cloudflare challenge page, cross-series chapter links, decimal chapters, and both raw `&` and `&` forms.
- Poller tests cover the no-reorder invariant, cooldown enforcement across passes, batch limiting, one bad series not stalling a batch, downward correction on a retracted chapter, cancelled contexts, and all four failure shapes still consuming the cooldown.
- Migration from a pre-column database has its own test — `newTestStore` takes the `CREATE TABLE` path, so the `ALTER TABLE` path would otherwise be untested.
- **Live smoke test:** real server, real fetch of asurascans.com. Log showed `latest is now Chapter 181` and `due=1 checked=1`; `GET /bookmarks` returned `latest_chapter_num: 181` with `updated_at` byte-identical to the PUT that created the row — the no-reorder invariant confirmed against a live site, not just a fake.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #2
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
725 lines
22 KiB
Go
725 lines
22 KiB
Go
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)
|
|
}
|
|
}
|