package main import ( "fmt" "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-']) rather than a bare CSS id-selector (#card-). // // 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) } // 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, _ := 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) } } }) } } // 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, store := newWebTestServer(t, cfg) seedStatusRows(t, store) // 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 := Bookmark{ Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne", Status: statusReading, LastChapterNum: 40, LatestChapter: "40", LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(), } if _, err := store.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 := store.Get("asura:reading") if err != nil { t.Fatalf("Get: %v", err) } reading.LatestChapterNum = floatPtr(reading.LastChapterNum) if _, err := store.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 recentCount, however many series are waiting. func TestRecentStripCapped(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) for i := 0; i <= recentCount; i++ { b := Bookmark{ Key: fmt.Sprintf("asura:new%d", i), Site: "asura", SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i), Status: statusReading, LastChapterNum: 1, LatestChapter: "2", LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i), } if _, err := store.Upsert(b); err != nil { t.Fatalf("seed %s: %v", b.Key, err) } } if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount { t.Fatalf("strip rendered %d cards, want %d", got, 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, 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) } } } // 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, store := newWebTestServer(t, cfg) seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", Status: 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": {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) } }