Files
mangaBookmark/backend/store_test.go
T
sulthan 6af49e6790 Archived and finished buckets, userscript nav chips (#4)
Gives every bookmark a lifecycle bucket — `reading`, `archived`, or `finished` — so on-hold series leave the main list while still being polled for new chapters, completed series get a web-only bucket, and the userscript panel gains quick links to the web UI and both manga sites.

Design: `docs/superpowers/specs/2026-07-27-status-buckets-design.md`

## Data model

One additive column through the existing `addedColumns` migration list:

```sql
ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'
```

The `DEFAULT` backfills every pre-existing row as `reading`, so there is no separate migration step. `favorite` is unchanged and orthogonal — a series can be an archived favourite.

Rollback is safe: an old binary against the new database omits `status` from its INSERT (it gets the DEFAULT) and never mentions it in the conflict clause, so buckets survive.

## The write rule

`PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes every column it knows about. `latest_checked_at` escaped this by staying out of `bookmarkColumns` entirely — `status` cannot, because the userscript must be able to archive and restore.

So an empty incoming status means **"no opinion"**, not a value, and resolves on the `VALUES` side of the upsert:

```sql
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')
```

with `DO UPDATE SET status = excluded.status`.

It has to be this way round. `excluded.*` is the row *after* the `VALUES` expressions are evaluated, so applying the default there and then reading `excluded.status` in the conflict clause would see `'reading'` rather than the empty string — and would overwrite an archived row on every progress PUT from a client that knows nothing about the column. One expression, evaluated once, covers insert and update alike. The subquery runs inside the transaction, so it sees the row the statement is about to conflict with.

`TestUpsertEmptyStatusPreservesStored` is the guard on this.

`updated_at` behaviour is unchanged: it moves only when `last_chapter_num` changes, so archiving, finishing, restoring, and favouriting never reorder the list.

## Validation

`PUT /bookmarks/{key}` returns 400 for any status outside `{"", "reading", "archived", "finished"}`, and for `"finished"` specifically. Finishing a series is a web-UI decision, enforced server-side rather than by trusting every client to leave the value alone. The `/ui/*` endpoints have their own session-guarded route and are unaffected.

## Visibility

| Surface | All | Updated | Favourites | Archived | Finished |
|---|---|---|---|---|---|
| Web | reading | reading | reading | archived | finished |
| Userscript | reading | — | reading | archived | not shown |

Archived and finished appear in their own tab and nowhere else — including the web UI's "Continue reading" strip, which is now built from reading-only rows before tab filtering. An archived favourite shows up under Archived only: Favourites means "favourites I am currently reading".

## Backend

- **`store.go`** — `Bookmark.Status`, the column in `schema` / `addedColumns` / `bookmarkColumns` / `scanBookmark` / `Upsert`. `scanBookmark` normalises anything outside the three known buckets to `reading`, so no row can land in no list at all.
- **`store.go`** — `DueForLatestCheck` gains `AND status IS NOT 'finished'`. Archived series keep being polled; that is the whole point of archiving rather than deleting. Finished ones have nothing coming, so polling them only burns fetches and risks a spurious "new chapter" badge. `IS NOT` is null-safe, so a hand-edited NULL still qualifies.
- **`handlers.go`** — status validation on `PUT`, before any write.
- **`web.go`** — `buildListView` filters the new tabs and excludes both buckets from `all` / `new` / `fav` and the recent strip; new `POST /ui/bookmarks/{key}/status`, session-guarded like its siblings, read-modify-writing through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one place.
- **`templates/`** — two more tabs; per-card controls (reading → Archive + Finish, archived → Restore + Finish, finished → Restore); empty-state copy for both new tabs.
- **`static/style.css`** — five tabs no longer divide a phone's width legibly, so the row scrolls sideways instead of squeezing.

## Userscript (1.3.0)

- Third tab **Archived** beside All and Favourites. A missing `status` reads as `reading`, so a list cached by the previous version still renders. `finished` matches no tab and is invisible everywhere.
- Per-item **Archive / Unarchive** button on the existing optimistic path: mutate local state and cache, `apiPut`, adopt the server's returned row.
- Header chip row linking the web UI and both manga sites, each `target="_blank" rel="noopener"`.
- `apiPut` now omits `status` unless the caller opts in — see below.

Still free of every `GM_*` API: plain `fetch`, page `localStorage`, on-page UI only.

## One bug worth calling out

The userscript's other mutations (`updateToCurrentChapter`, `setChapterManual`, `toggleFavorite`, `applyLatestChapterIfChanged`) build their payload with `Object.assign({}, existing, …)`, so they echoed the cached `status` back to the server. `GET /bookmarks` has no status filter — finished rows are in `state.list` and only hidden at render time — which made two failures reachable:

1. Reading a chapter of a series marked finished sent `"status":"finished"`, which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently.
2. Archiving on desktop and then reading on a phone whose cache predated the archive sent `"status":"reading"` and silently un-archived the series — contradicting the README's "reading an archived series leaves it archived".

Fixed at the single choke point: `apiPut(key, obj, { sendStatus = false })` strips `status` from a copy of the body unless the caller opts in, and only `toggleArchive` opts in. Only an explicit archive/restore has an opinion about the bucket; everything else omits the field so the server's keep-on-empty rule applies. Stripping merely the *invalid* values would not have been enough — a stale cached `"reading"` still clobbers a remote archive.

Also: the userscript's `backgroundRefreshLatest` now skips finished series, matching the server poller, instead of spending batch slots fetching pages for a series that has nothing coming.

## Known limitations, deliberate

Both are marked in-code with `ponytail:` comments naming the ceiling and the upgrade path:

- The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so a client PUT that commits between the two is lost to the stale re-read. Already documented for read progress in `CLAUDE.md`; it now costs a status change too. Accepted for a single-user deployment.
- A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback.

## Testing

`go test ./...` passes; `CGO_ENABLED=0 go build ./...` clean.

- **`store_test.go`** — a fresh row defaults to `reading`; a legacy database gains the column with every row `reading`; an `Upsert` carrying `""` preserves the stored bucket while a value replaces it; a status change does not move `updated_at`; `DueForLatestCheck` returns archived and skips finished; the poller's `Get` → `Upsert` round trip preserves `archived`.
- **`main_test.go`** — `PUT` with `finished` or garbage is 400, `""` / `reading` / `archived` round-trip; a PUT that omits the `status` key entirely (what a pre-1.3.0 userscript sends) preserves an archived bucket *and* applies the chapter progress in the same request.
- **`web_test.go`** — each tab returns only its bucket; the recent strip excludes archived and finished; the status endpoint requires a session, rejects unknown values, and does not move `updated_at`; the card renders the right controls per bucket.

Userscript has no automated harness, so it was checked against a live `https://asurascans.com` page: the chips resolve, Archive moves a series out of All and Favourites into Archived, the state survives a full reload (so it came from the server, not local optimism), Unarchive returns it, a series marked finished in the web UI appears in no tab, and — captured on the wire — the archive PUT carries `"status":"archived"` while a favourite toggle on that same archived series carries no `status` key at all.

Reviewed-on: #4
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-27 16:58:05 +07:00

908 lines
27 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)
}
}
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")
}
}