6af49e6790
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>
733 lines
24 KiB
Go
733 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testPassword = "hunter2"
|
|
|
|
func webConfig() Config {
|
|
cfg := testConfig()
|
|
cfg.WebPassword = testPassword
|
|
return cfg
|
|
}
|
|
|
|
// newWebTestServer returns the full router plus the store behind it, so tests
|
|
// can seed rows and assert on what the handlers wrote back.
|
|
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *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 newRouter(store, cfg), store
|
|
}
|
|
|
|
// sessionCookie returns a cookie a handler will accept for cfg's API token.
|
|
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
|
|
t.Helper()
|
|
return &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
|
}
|
|
}
|
|
|
|
func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `type="password"`) {
|
|
t.Fatal("GET / without a session did not render the password field")
|
|
}
|
|
}
|
|
|
|
func TestIndexWithSessionShowsList(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
if _, err := store.Upsert(Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
UpdatedAt: time.Now().UnixMilli(),
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Solo Leveling") {
|
|
t.Fatal("GET / with a session did not render the bookmark title")
|
|
}
|
|
}
|
|
|
|
func TestLoginSuccessSetsCookie(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
req := httptest.NewRequest(http.MethodPost, "/login",
|
|
strings.NewReader(url.Values{"password": {testPassword}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST /login status = %d, want 303", rr.Code)
|
|
}
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
|
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName)
|
|
}
|
|
}
|
|
|
|
func TestLoginWrongPassword(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
req := httptest.NewRequest(http.MethodPost, "/login",
|
|
strings.NewReader(url.Values{"password": {"wrong"}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("POST /login status = %d, want 401", rr.Code)
|
|
}
|
|
if len(rr.Result().Cookies()) != 0 {
|
|
t.Fatal("a failed login set a cookie")
|
|
}
|
|
}
|
|
|
|
func TestLoginRateLimited(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
post := func() *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/login",
|
|
strings.NewReader(url.Values{"password": {"wrong"}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
for i := 0; i < loginMaxFailures; i++ {
|
|
if code := post().Code; code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
|
|
}
|
|
}
|
|
rr := post()
|
|
if rr.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code)
|
|
}
|
|
if after := rr.Header().Get("Retry-After"); after == "" {
|
|
t.Fatal("429 response has no Retry-After header")
|
|
} else if n, err := strconv.Atoi(after); err != nil || n <= 0 {
|
|
t.Fatalf("Retry-After = %q, want a positive integer", after)
|
|
}
|
|
}
|
|
|
|
func TestLogoutClearsCookie(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, _ := newWebTestServer(t, cfg)
|
|
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST /logout status = %d, want 303", rr.Code)
|
|
}
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 || cookies[0].MaxAge >= 0 {
|
|
t.Fatalf("POST /logout cookies = %+v, want one expiring cookie", cookies)
|
|
}
|
|
}
|
|
|
|
func TestWebDisabledWhenNoPassword(t *testing.T) {
|
|
cfg := testConfig() // WebPassword empty
|
|
srv, _ := newWebTestServer(t, cfg)
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET / with WEB_PASSWORD unset = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestBookmarksAPIStillBearerOnly(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, _ := newWebTestServer(t, cfg)
|
|
|
|
// A session cookie must not grant access to the userscript's JSON API.
|
|
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("GET /bookmarks with only a cookie = %d, want 401", rr.Code)
|
|
}
|
|
|
|
// And the bearer token must still work.
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET /bookmarks with bearer = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestStaticAssetsServed(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js"} {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200", path, rr.Code)
|
|
}
|
|
if rr.Body.Len() == 0 {
|
|
t.Fatalf("GET %s returned an empty body", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// seed inserts one bookmark and returns it as stored.
|
|
func seed(t *testing.T, store *Store, b Bookmark) Bookmark {
|
|
t.Helper()
|
|
stored, err := store.Upsert(b)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
return stored
|
|
}
|
|
|
|
func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *http.Request {
|
|
t.Helper()
|
|
var req *http.Request
|
|
if form == nil {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
} else {
|
|
req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
return req
|
|
}
|
|
|
|
func TestUIRoutesRequireSession(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
cases := []struct{ method, path string }{
|
|
{http.MethodGet, "/ui/list"},
|
|
{http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
|
|
{http.MethodPost, "/ui/bookmarks/asura:solo/chapter"},
|
|
{http.MethodDelete, "/ui/bookmarks/asura:solo"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rr.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
before := seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
after, ok, err := store.Get("asura:solo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
|
}
|
|
if !after.Favorite {
|
|
t.Fatal("Favorite = false after toggling, want true")
|
|
}
|
|
if after.UpdatedAt != before.UpdatedAt {
|
|
t.Fatalf("UpdatedAt moved from %d to %d; favouriting must not reorder the list",
|
|
before.UpdatedAt, after.UpdatedAt)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `id="card-asura:solo"`) {
|
|
t.Fatal("favorite response did not render the card fragment")
|
|
}
|
|
|
|
// Toggling again turns it back off.
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
|
back, _, _ := store.Get("asura:solo")
|
|
if back.Favorite {
|
|
t.Fatal("Favorite = true after a second toggle, want false")
|
|
}
|
|
}
|
|
|
|
// TestCardHxTargetIsValidSelectorForColonKey asserts the rendered card's
|
|
// hx-target attributes use the fixed-string attribute-selector form
|
|
// ([id='card-<key>']) rather than a bare CSS id-selector (#card-<key>).
|
|
//
|
|
// A key like "asura:solo" makes "#card-asura:solo" an invalid CSS selector:
|
|
// the browser parses ":solo" as an unrecognised pseudo-class and htmx's
|
|
// querySelectorAll throws SyntaxError, so the button never resolves its
|
|
// swap target. httptest never executes htmx, so this only checks the
|
|
// rendered attribute's shape — it is not proof the browser accepts the
|
|
// selector, just a regression guard against reintroducing the bare-id form.
|
|
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
|
|
want := `hx-target="[id='card-asura:solo']"`
|
|
if strings.Count(body, want) != 5 {
|
|
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
|
|
strings.Count(body, want), want)
|
|
}
|
|
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
|
t.Fatal("body still uses the bare id CSS selector, which is invalid for a key containing ':'")
|
|
}
|
|
}
|
|
|
|
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
before := seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
|
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"60"}}))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
after, ok, err := store.Get("asura:solo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
|
}
|
|
if after.LastChapterNum != 60 || after.LastChapter != "60" {
|
|
t.Fatalf("chapter = %q/%v, want 60", after.LastChapter, after.LastChapterNum)
|
|
}
|
|
if after.UpdatedAt <= before.UpdatedAt {
|
|
t.Fatalf("UpdatedAt = %d, want later than %d", after.UpdatedAt, before.UpdatedAt)
|
|
}
|
|
if after.LastChapterURL != "" {
|
|
t.Fatalf("LastChapterURL = %q, want cleared by a manual override", after.LastChapterURL)
|
|
}
|
|
if after.Title != "Solo Leveling" {
|
|
t.Fatalf("Title = %q, want the untouched fields preserved", after.Title)
|
|
}
|
|
}
|
|
|
|
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
before := seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
// The chapter form is pre-filled with the current value, so tapping Save
|
|
// without editing resubmits the unchanged number. That must be a no-op: it
|
|
// must not clear last_chapter_url, rewrite the last_chapter display string,
|
|
// or move updated_at. The seed stores "45.0" against 45 so the display
|
|
// string differs from what the form submits back.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
|
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"45"}}))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
after, ok, err := store.Get("asura:solo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
|
}
|
|
if after.LastChapterURL != before.LastChapterURL {
|
|
t.Fatalf("LastChapterURL = %q, want preserved %q on a no-op save",
|
|
after.LastChapterURL, before.LastChapterURL)
|
|
}
|
|
if after.LastChapter != before.LastChapter {
|
|
t.Fatalf("LastChapter = %q, want preserved %q on a no-op save",
|
|
after.LastChapter, before.LastChapter)
|
|
}
|
|
if after.UpdatedAt != before.UpdatedAt {
|
|
t.Fatalf("UpdatedAt = %d, want unchanged %d on a no-op save",
|
|
after.UpdatedAt, before.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
|
|
t.Run("input "+bad, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
|
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {bad}}))
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
after, _, _ := store.Get("asura:solo")
|
|
if after.LastChapterNum != 45 {
|
|
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMutationsOnMissingKey(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, _ := newWebTestServer(t, cfg)
|
|
cases := []struct {
|
|
name string
|
|
req *http.Request
|
|
}{
|
|
{"favorite", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)},
|
|
{"chapter", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, tc.req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUIDeleteRemovesRow(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodDelete, "/ui/bookmarks/asura:solo", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("delete status = %d, want 200", rr.Code)
|
|
}
|
|
if rr.Body.Len() != 0 {
|
|
t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String())
|
|
}
|
|
if _, ok, _ := store.Get("asura:solo"); ok {
|
|
t.Fatal("row still present after delete")
|
|
}
|
|
}
|
|
|
|
func TestUIListFavouritesTab(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
|
})
|
|
seed(t, store, Bookmark{
|
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
|
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=fav", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "Solo Leveling") {
|
|
t.Fatal("favourites tab omitted the favourited series")
|
|
}
|
|
if strings.Contains(body, "Tower of God") {
|
|
t.Fatal("favourites tab included a non-favourite")
|
|
}
|
|
}
|
|
|
|
func TestUIListNewTab(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seed(t, store, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapterNum: 10,
|
|
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
|
UpdatedAt: 2_000_000,
|
|
})
|
|
seed(t, store, Bookmark{
|
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
|
Title: "Tower of God", LastChapterNum: 5,
|
|
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=new", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "Solo Leveling") {
|
|
t.Fatal("new tab omitted the series with an unread chapter")
|
|
}
|
|
if strings.Contains(body, "Tower of God") {
|
|
t.Fatal("new tab included a series already caught up")
|
|
}
|
|
}
|
|
|
|
// seedStatusRows puts one series in each bucket, the archived one also
|
|
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
|
// shows up as a failure rather than passing by accident.
|
|
func seedStatusRows(t *testing.T, store *Store) {
|
|
t.Helper()
|
|
// floatPtr already exists in store_test.go — same package, reuse it.
|
|
rows := []Bookmark{
|
|
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
|
Status: statusReading, LastChapterNum: 10, Favorite: true,
|
|
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
|
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
|
Status: statusArchived, LastChapterNum: 5, Favorite: true,
|
|
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
|
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
|
Status: statusFinished, LastChapterNum: 200, Favorite: true},
|
|
}
|
|
for _, b := range rows {
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
if _, err := store.Upsert(b); err != nil {
|
|
t.Fatalf("seed %s: %v", b.Key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
cases := []struct {
|
|
tab string
|
|
want, dontWant []string
|
|
}{
|
|
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
|
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
|
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
|
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
|
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.tab, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
for _, w := range tc.want {
|
|
if !strings.Contains(body, w) {
|
|
t.Fatalf("tab %s missing %s", tc.tab, w)
|
|
}
|
|
}
|
|
for _, d := range tc.dontWant {
|
|
if strings.Contains(body, d) {
|
|
t.Fatalf("tab %s leaked %s", tc.tab, d)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecentStripExcludesArchivedAndFinished(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
strip := rr.Body.String()
|
|
if i := strings.Index(strip, `id="list"`); i >= 0 {
|
|
strip = strip[:i] // everything above the list is the recent section
|
|
}
|
|
if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") {
|
|
t.Fatal("recent strip included an archived or finished series")
|
|
}
|
|
if !strings.Contains(strip, "ReadingOne") {
|
|
t.Fatal("recent strip dropped the reading series")
|
|
}
|
|
}
|
|
|
|
func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
form := url.Values{"status": {status}}
|
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/"+key+"/status",
|
|
strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestUIStatusSetsBucket(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
for _, want := range []string{statusArchived, statusFinished, statusReading} {
|
|
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
|
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
|
}
|
|
b, ok, err := store.Get("asura:reading")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
|
}
|
|
if b.Status != want {
|
|
t.Fatalf("stored status = %q, want %q", b.Status, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
b, _, _ := store.Get("asura:reading")
|
|
if b.Status != statusReading {
|
|
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
|
}
|
|
}
|
|
|
|
func TestUIStatusRequiresSession(t *testing.T) {
|
|
srv, store := newWebTestServer(t, webConfig())
|
|
seedStatusRows(t, store)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
|
strings.NewReader("status=archived"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
before, _, _ := store.Get("asura:reading")
|
|
time.Sleep(2 * time.Millisecond)
|
|
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rr.Code)
|
|
}
|
|
after, _, _ := store.Get("asura:reading")
|
|
if after.UpdatedAt != before.UpdatedAt {
|
|
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestCardShowsStatusControls(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
cases := []struct {
|
|
tab string
|
|
want, dontWant []string
|
|
}{
|
|
// A series being read can be shelved or completed, not restored.
|
|
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
|
// An archived one can come back or be completed.
|
|
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
|
// A finished one can only come back.
|
|
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.tab, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
body := rr.Body.String()
|
|
for _, w := range tc.want {
|
|
if !strings.Contains(body, w) {
|
|
t.Fatalf("tab %s missing control %s", tc.tab, w)
|
|
}
|
|
}
|
|
for _, d := range tc.dontWant {
|
|
if strings.Contains(body, d) {
|
|
t.Fatalf("tab %s offered %s", tc.tab, d)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppRendersNewTabs(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, store := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, store)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie(t, cfg))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
|
|
if !strings.Contains(rr.Body.String(), want) {
|
|
t.Fatalf("app page missing %s", want)
|
|
}
|
|
}
|
|
}
|