5aae678668
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
602 lines
20 KiB
Go
602 lines
20 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) != 3 {
|
|
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, 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")
|
|
}
|
|
}
|