Files
mangaBookmark/backend/web_test.go
sulthan 4229c179b0 rebrand: MangaBM → BookmarkManager, add novel library support (#15)
Two intertwined changes — the rebrand and the novel library were developed on
the same branch because the novel UI plumbing is part of the new "Bookmark
Manager" wordmark in the web shell.

## What it does

- **Rebrand**: MangaBM → BookmarkManager across the Go module, compose stack,
  env vars, Traefik hostnames, container/image names, userscript storage
  prefixes (`mangabm:cache` → `bmgr:manga:cache`, `mangabm:queue` → `bmgr:manga:queue`),
  and docs.
- **Novel library**: same backend, two libraries. New `kind` column splits
  bookmarks into `manga` / `novel`; PUT validates it. Two userscripts:
  - `manga-bookmark.user.js` — unchanged behaviour, just stamps its own `kind`.
  - `novel-bookmark.user.js` — separate Violentmonkey install with adapters
    for **novelfull.com** (polled via headless browser — Cloudflare JS
    challenge) and **lightnovelworld.net** (polled via plain TLS).
- **Web UI**: library switch on the app shell. Login art, libswitch, and
  novel-site colours from the Cinder design snapshot.

## Plumbing

- `addedColumns` ALTER for `kind` runs on first start after upgrade; every
  pre-existing row is backfilled to `'manga'`. No manual SQL, no down-time.
- `ALLOWED_ORIGINS` gains the two novel sites.
- New `NOVEL_USERSCRIPT_PATH` env (default `/userscript/novel-bookmark.user.js`),
  bindmounted alongside the manga script.
- Traefik router names `mangabm*` → `bmapi*` / `bmweb*`.

## Test status

- `go test ./...` — green
- `node --test userscript/test/logic.test.js` — 34 pass
- `node --test userscript/test/novel-logic.test.js` — 11 pass
- `node --check` on both userscripts — clean

## Notes for the redeploy

.env keys were renamed (`MANGA_API_HOST` → `BOOKMARK_API_HOST`,
`MANGA_WEB_HOST` → `BOOKMARK_WEB_HOST`). Update DNS / Traefik labels on the
prod override before pulling, otherwise the public hostnames go dark.
See the redeploy instructions I'll post next to this PR.

Reviewed-on: #15
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-06 03:58:23 +07:00

967 lines
32 KiB
Go

package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
)
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.Store) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return newRouter(st, cfg), st
}
// 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: session.CookieName,
Value: session.Sign(session.Key(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, st := newWebTestServer(t, cfg)
if _, err := st.Upsert(store.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 != session.CookieName || cookies[0].Value == "" {
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
}
}
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 < session.MaxFailures; 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", session.MaxFailures+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", "/static/logo.svg", "/static/login-art.png"} {
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, st *store.Store, b store.Bookmark) store.Bookmark {
t.Helper()
stored, err := st.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, st := newWebTestServer(t, cfg)
before := seed(t, st, 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 := st.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, _, _ := st.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, st := newWebTestServer(t, cfg)
seed(t, st, 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, st := newWebTestServer(t, cfg)
before := seed(t, st, 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 := st.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, st := newWebTestServer(t, cfg)
before := seed(t, st, 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 := st.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, st := newWebTestServer(t, cfg)
seed(t, st, 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, _, _ := st.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, st := newWebTestServer(t, cfg)
seed(t, st, 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)
}
// The body carries only out-of-band chrome, so htmx has nothing to swap into
// the card's place and the row disappears.
body := rr.Body.String()
if strings.Contains(body, `class="card`) {
t.Fatalf("delete body = %q, want no card so htmx swaps it away", body)
}
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
t.Fatalf("delete body = %q, want the out-of-band badge", body)
}
if _, ok, _ := st.Get("asura:solo"); ok {
t.Fatal("row still present after delete")
}
}
func TestUIListFavouritesTab(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
})
seed(t, st, 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, st := newWebTestServer(t, cfg)
seed(t, st, 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, st, 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, st *store.Store) {
t.Helper()
// floatPtr already exists in store_test.go — same package, reuse it.
rows := []store.Bookmark{
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
}
for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli()
if _, err := st.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
}
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
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)
}
}
})
}
}
// stripOf returns everything above the list, which is where the recent section
// renders.
func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/?tab="+tab, nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
body := rr.Body.String()
if i := strings.Index(body, `id="list"`); i >= 0 {
body = body[:i]
}
return body
}
// The strip carries the series with a chapter waiting — the one thing the
// updated_at-ordered list below it does not already say — and only on All.
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
// A reading series that is caught up has nothing waiting, so it stays out.
caught := store.Bookmark{
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
}
if _, err := st.Upsert(caught); err != nil {
t.Fatalf("seed %s: %v", caught.Key, err)
}
strip := stripOf(t, srv, cfg, "all")
if !strings.Contains(strip, "ReadingOne") {
t.Fatal("strip dropped the series with an unread chapter")
}
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} {
if strings.Contains(strip, unwanted) {
t.Fatalf("strip included %s", unwanted)
}
}
for _, tab := range []string{"new", "fav", "archived", "finished"} {
if strings.Contains(stripOf(t, srv, cfg, tab), "ReadingOne") {
t.Fatalf("tab %s rendered the strip", tab)
}
}
// Nothing new anywhere: the strip has nothing to say and does not render.
reading, _, err := st.Get("asura:reading")
if err != nil {
t.Fatalf("Get: %v", err)
}
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
if _, err := st.Upsert(reading); err != nil {
t.Fatalf("Upsert: %v", err)
}
// The section still ships (an out-of-band swap needs the id to exist) but
// carries no cards and is hidden.
empty := stripOf(t, srv, cfg, "all")
if strings.Contains(empty, "recent-card") {
t.Fatal("strip rendered cards with no unread chapters anywhere")
}
if !strings.Contains(empty, `id="recent" hidden`) {
t.Fatalf("strip not hidden with nothing new: %q", empty)
}
}
// The strip never grows past web.RecentCount, however many series are waiting.
func TestRecentStripCapped(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
for i := 0; i <= web.RecentCount; i++ {
b := store.Bookmark{
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
}
if _, err := st.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
}
}
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, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.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 := st.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, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
b, _, _ := st.Get("asura:reading")
if b.Status != store.StatusReading {
t.Fatalf("stored status = %q, want it untouched", b.Status)
}
}
func TestUIStatusRequiresSession(t *testing.T) {
srv, st := newWebTestServer(t, webConfig())
seedStatusRows(t, st)
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, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
before, _, _ := st.Get("asura:reading")
time.Sleep(2 * time.Millisecond)
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
after, _, _ := st.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, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
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, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
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)
}
}
}
// A mutation has to bring the chrome with it: the strip and the badge live
// outside the swapped card, so nothing else would correct them.
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
})
before := stripOf(t, srv, cfg, "all")
if !strings.Contains(before, "Solo Leveling") || !strings.Contains(before, `id="new-count"`) {
t.Fatalf("expected the series in the strip to start with: %q", before)
}
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
url.Values{"status": {store.StatusArchived}})
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status post = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="recent" hx-swap-oob="true" hidden`) {
t.Fatalf("archiving did not empty the strip out of band: %q", body)
}
if !strings.Contains(body, `id="new-count" hx-swap-oob="true" hidden`) {
t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
}
}
// seedLibraries puts one manga and one novel row in the store.
func seedLibraries(t *testing.T, st *store.Store) {
t.Helper()
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", Kind: store.KindManga, UpdatedAt: 2_000_000,
})
seed(t, st, store.Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Title: "A Will Eternal",
Kind: store.KindNovel, UpdatedAt: 1_000_000,
})
}
func TestLibrariesAreDisjoint(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
cases := []struct {
name, path, want, absent string
}{
{"manga is the default", "/ui/list?tab=all", "Solo Leveling", "A Will Eternal"},
{"novel is opt-in", "/ui/list?lib=novel&tab=all", "A Will Eternal", "Solo Leveling"},
{"unknown lib falls back to manga", "/ui/list?lib=comics&tab=all", "Solo Leveling", "A Will Eternal"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, tc.path, nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, tc.want) {
t.Fatalf("%s missing from %s", tc.want, tc.path)
}
if strings.Contains(body, tc.absent) {
t.Fatalf("%s leaked into %s", tc.absent, tc.path)
}
})
}
}
// A row written before the kind column existed has none. It is manga.
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
Title: "Legacy Series", UpdatedAt: 1_000_000,
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=all", nil))
if !strings.Contains(rr.Body.String(), "Legacy Series") {
t.Fatal("a row with no kind must appear in the manga library")
}
}
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?lib=novel&tab=all", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if strings.Contains(body, "tab=new") {
t.Fatal("novel page must not offer the Updated tab")
}
// html/template escapes & to &amp; inside an attribute value, so that — not
// the raw URL — is what lands in the body. htmx and the browser both decode
// it on read, so only the assertion has to know.
for _, want := range []string{
"/?lib=novel&amp;tab=fav",
"/?lib=novel&amp;tab=archived",
"/?lib=novel&amp;tab=finished",
} {
if !strings.Contains(body, want) {
t.Fatalf("novel page missing tab link %s", want)
}
}
if !strings.Contains(body, `class="libswitch"`) {
t.Fatal("novel page missing the library switch")
}
}
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?tab=all", nil))
body := rr.Body.String()
if !strings.Contains(body, "/?tab=new") {
t.Fatal("manga page must keep the Updated tab")
}
if strings.Contains(body, "lib=novel&amp;tab=new") {
t.Fatal("the Updated tab must never be emitted for the novel library")
}
}
// tab=new is not offered for novels, so a hand-typed one must land on All
// rather than an empty page.
func TestNovelNewTabFallsBackToAll(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?lib=novel&tab=new", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "A Will Eternal") {
t.Fatal("novel tab=new should render the novel All list")
}
}