Files
mangaBookmark/backend/web_test.go
T
sulthan bc64a1d894 backend: remove orphan series from the admin (ticket #155)
(*Store).RemoveSeries deletes one series row by (site, series_id) via a
plain parameterized DELETE; a bookmarks_series_fk violation outside
23503 is translated into the ErrSeriesHasBookmarks sentinel so no
driver type escapes the store. The caller reads the row's cover
address before the delete and reclaims it after: ReclaimCover's guard
cannot pass while a series row still points at the address.

POST /admin/series/{key}/remove answers the list row with the removed
row's fragment plus the heading re-rendered out of band at the fresh
count (HX-Reswap: delete removes the row through the same button that
swaps the refusal back in), and navigates from the detail page to the
No-Readers list (HX-Redirect for htmx, a 303 for plain clients). A
removal that races a fresh Bookmark is a refusal, not a 500: the row
re-renders at its new count with the fact spelled out. The control
renders only at zero Reader count on both surfaces, gated by hx-confirm
with the brief's copy.
2026-08-22 09:55:01 +07:00

3781 lines
144 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
)
// testOwnerID is the Discord identity the stub reports for a sign-in. It is
// deliberately not the seeded owner's (testDiscordID): registration is open,
// so the default sign-in is a second Reader registering.
const testOwnerID = "owner-snowflake"
// newWebTestServer returns the full router plus the store behind it, so tests
// can seed rows and assert on what the handlers wrote back. The Lanes page
// reads the pass log (issue #145), so a test seeds store rows rather than
// standing in for a poller.
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
return newRouter(st, cfg), st
}
// sessionCookie mints a live session row for the owner and returns the cookie
// carrying its id — the only credential the UI accepts.
func sessionCookie(t *testing.T, st *store.Store) *http.Cookie {
t.Helper()
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), session.SessionTTL)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: session.CookieName, Value: sess.ID}
}
// discordStub is a minimal Discord API. The router is pointed at it through
// the configured API base URL, so the real request construction — including
// the form-encoded token exchange — is what the tests exercise, not an
// injected client interface.
type discordStub struct {
ownerID string // id /users/@me answers
member bool // whether the member endpoint reports membership
roles []string // roles the member holds
tokenStatus int // status the token endpoint answers; 0 = 200
userStatus int // status users/@me answers; 0 = 200
memberStatus int // status the member endpoint answers; 0 = member ? 200 : 404
tokenRequests []tokenRequest // recorded token exchanges
userAuth []string // Authorization headers seen on users/@me
memberAuth []string // Authorization headers seen on the member endpoint
memberPaths []string
}
type tokenRequest struct {
contentType string
form url.Values
}
func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) {
t.Helper()
st := &discordStub{ownerID: testOwnerID, member: true}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/oauth2/token":
body, _ := io.ReadAll(r.Body)
form, _ := url.ParseQuery(string(body))
st.tokenRequests = append(st.tokenRequests, tokenRequest{
contentType: r.Header.Get("Content-Type"),
form: form,
})
status := st.tokenStatus
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if status == http.StatusOK {
fmt.Fprintf(w, `{"access_token":"tok-%d","token_type":"Bearer"}`, len(st.tokenRequests))
}
case r.URL.Path == "/users/@me":
st.userAuth = append(st.userAuth, r.Header.Get("Authorization"))
status := st.userStatus
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if status == http.StatusOK {
fmt.Fprintf(w, `{"id":%q,"username":"owner"}`, st.ownerID)
}
// Discord answers the bot endpoint with 401 for a user Bearer token.
// Standing in for that keeps a regression onto it loud: without this
// the request would fall through to 404 and read as "not a member",
// which is a refusal the caller treats as ordinary.
case strings.HasPrefix(r.URL.Path, "/guilds/"):
w.WriteHeader(http.StatusUnauthorized)
case strings.HasPrefix(r.URL.Path, "/users/@me/guilds/"):
st.memberPaths = append(st.memberPaths, r.URL.Path)
st.memberAuth = append(st.memberAuth, r.Header.Get("Authorization"))
status := st.memberStatus
if status == 0 {
if st.member {
status = http.StatusOK
} else {
status = http.StatusNotFound
}
}
w.WriteHeader(status)
if status == http.StatusOK {
roles, _ := json.Marshal(st.roles)
fmt.Fprintf(w, `{"roles":%s}`, roles)
}
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return st, srv
}
// discordConfig is the OAuth application config every sign-in test uses, with
// the API base pointed at a stub.
func discordConfig(stubURL string) web.DiscordConfig {
return web.DiscordConfig{
ClientID: "client-1",
ClientSecret: "client-secret-1",
GuildID: "guild-1",
APIBase: stubURL,
RedirectURI: "https://bm.example.com/auth/discord/callback",
}
}
// oauthWebTestServer returns the full router, its store, and a Discord stub
// wired as the configured API — the starting point for sign-in tests.
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
t.Helper()
stub, srv := newDiscordStub(t)
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, st := newWebTestServer(t, cfg)
return router, st, stub
}
// startSignIn runs GET /auth/discord and returns the state Discord would echo
// back. A failed start fails the test.
func startSignIn(t *testing.T, srv http.Handler) string {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/auth/discord", nil))
if rr.Code != http.StatusSeeOther {
t.Fatalf("GET /auth/discord status = %d, want 303", rr.Code)
}
loc, err := url.Parse(rr.Header().Get("Location"))
if err != nil {
t.Fatalf("Location %q: %v", rr.Header().Get("Location"), err)
}
if loc.Path != "/oauth2/authorize" {
t.Fatalf("redirect path = %q, want /oauth2/authorize", loc.Path)
}
if state := loc.Query().Get("state"); state != "" {
return state
}
t.Fatal("authorize URL carries no state")
return ""
}
// completeSignIn drives the callback with a fresh code for state.
func completeSignIn(t *testing.T, srv http.Handler, state string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet,
"/auth/discord/callback?code=discord-code-1&state="+url.QueryEscape(state), nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// storeReaders is the roster, ordered oldest first — the owner heads it.
func storeReaders(t *testing.T, st *store.Store) []store.ReaderSummary {
t.Helper()
readers, err := st.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
return readers
}
// signInCookie runs a whole Discord sign-in and returns the session cookie it
// minted, for the Reader the stub reports (testOwnerID).
func signInCookie(t *testing.T, srv http.Handler) *http.Cookie {
t.Helper()
rr := completeSignIn(t, srv, startSignIn(t, srv))
cookies := rr.Result().Cookies()
if rr.Code != http.StatusSeeOther || len(cookies) != 1 {
t.Fatalf("sign-in status = %d with %d cookies, want 303 and one", rr.Code, len(cookies))
}
return cookies[0]
}
// signedInReader is signInCookie plus the Reader the session names.
func signedInReader(t *testing.T, srv http.Handler, st *store.Store) int64 {
t.Helper()
sess, ok, err := st.GetSession(signInCookie(t, srv).Value, time.Now())
if err != nil || !ok {
t.Fatalf("session lookup: ok=%v err=%v", ok, err)
}
return sess.ReaderID
}
func TestIndexWithoutSessionShowsLogin(t *testing.T) {
srv, _ := newWebTestServer(t, testConfig())
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(), "Continue with Discord") {
t.Fatal("GET / without a session did not render the Discord sign-in button")
}
}
func TestIndexWithSessionShowsList(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
if _, err := st.Upsert(st.OwnerID(), 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, st))
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 TestDiscordLoginFullFlow(t *testing.T) {
srv, st, stub := oauthWebTestServer(t)
// The authorize redirect carries the app, the scopes the gate needs, and
// a fresh state.
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/auth/discord", nil))
if rr.Code != http.StatusSeeOther {
t.Fatalf("GET /auth/discord status = %d, want 303", rr.Code)
}
loc, err := url.Parse(rr.Header().Get("Location"))
if err != nil {
t.Fatalf("Location: %v", err)
}
q := loc.Query()
if q.Get("client_id") != "client-1" || q.Get("response_type") != "code" {
t.Fatalf("authorize query = %v, want client_id client-1 and response_type code", q)
}
if q.Get("redirect_uri") != "https://bm.example.com/auth/discord/callback" {
t.Fatalf("redirect_uri = %q, want the configured callback", q.Get("redirect_uri"))
}
for _, want := range []string{"identify", "guilds.members.read"} {
if !strings.Contains(q.Get("scope"), want) {
t.Fatalf("scope %q missing %s", q.Get("scope"), want)
}
}
state := q.Get("state")
if state == "" {
t.Fatal("authorize URL carries no state")
}
// The callback lands the reader logged in.
rr = completeSignIn(t, srv, state)
if rr.Code != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303 (body %s)", rr.Code, rr.Body.String())
}
cookies := rr.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
t.Fatalf("callback cookies = %+v, want one non-empty %s", cookies, session.CookieName)
}
// The token exchange went out form-encoded — the wire format Discord
// rejects if JSON — with every field Discord requires.
if len(stub.tokenRequests) != 1 {
t.Fatalf("token exchanges = %d, want 1", len(stub.tokenRequests))
}
tr := stub.tokenRequests[0]
if !strings.HasPrefix(tr.contentType, "application/x-www-form-urlencoded") {
t.Fatalf("token exchange Content-Type = %q, want form-urlencoded", tr.contentType)
}
wantForm := url.Values{
"client_id": {"client-1"},
"client_secret": {"client-secret-1"},
"grant_type": {"authorization_code"},
"code": {"discord-code-1"},
"redirect_uri": {"https://bm.example.com/auth/discord/callback"},
}
if !reflect.DeepEqual(tr.form, wantForm) {
t.Fatalf("token form = %v, want %v", tr.form, wantForm)
}
// Identity and membership were fetched with the exchanged token, and the
// membership check used the OAuth single-guild endpoint — the one
// guilds.members.read grants, not its bot-token twin.
if len(stub.userAuth) != 1 || stub.userAuth[0] != "Bearer tok-1" {
t.Fatalf("users/@me Authorization = %v, want [Bearer tok-1]", stub.userAuth)
}
if len(stub.memberPaths) != 1 || stub.memberPaths[0] != "/users/@me/guilds/guild-1/member" {
t.Fatalf("member requests = %v, want the OAuth single-guild endpoint", stub.memberPaths)
}
if len(stub.memberAuth) != 1 || stub.memberAuth[0] != "Bearer tok-1" {
t.Fatalf("member Authorization = %v, want [Bearer tok-1]", stub.memberAuth)
}
// The session row exists, and the cookie it minted opens the library.
if _, ok, err := st.GetSession(cookies[0].Value, time.Now()); err != nil || !ok {
t.Fatalf("session row: ok=%v err=%v, want ok", ok, err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookies[0])
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), "Continue with Discord") {
t.Fatalf("GET / with the new cookie = %d, still showing the login page", rr.Code)
}
}
func TestDiscordCallbackRejectsMissingState(t *testing.T) {
srv, _, stub := oauthWebTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/auth/discord/callback?code=discord-code-1", nil))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused callback set a cookie")
}
if len(stub.tokenRequests) != 0 || len(stub.userAuth) != 0 {
t.Fatal("a state-less callback still called Discord")
}
}
func TestDiscordCallbackRejectsMismatchedState(t *testing.T) {
srv, _, stub := oauthWebTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/auth/discord/callback?code=discord-code-1&state=not-the-state", nil))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused callback set a cookie")
}
if len(stub.tokenRequests) != 0 || len(stub.userAuth) != 0 {
t.Fatal("a mismatched-state callback still called Discord")
}
}
// A state is single-use: replaying a consumed callback is refused.
func TestDiscordCallbackStateIsSingleUse(t *testing.T) {
srv, _, _ := oauthWebTestServer(t)
state := startSignIn(t, srv)
if rr := completeSignIn(t, srv, state); rr.Code != http.StatusSeeOther {
t.Fatalf("first use status = %d, want 303", rr.Code)
}
rr := completeSignIn(t, srv, state)
if rr.Code != http.StatusBadRequest {
t.Fatalf("replayed state status = %d, want 400", rr.Code)
}
}
// TestDiscordLoginRefusesNonMember covers the refusals that must read the
// same: no membership, membership without the required role, and a member
// endpoint that answers 403 (token lacking the scope). Neither may leak the
// guild's existence or id, and neither may create anything.
func TestDiscordLoginRefusesNonMember(t *testing.T) {
cases := []struct {
name string
member bool
memberStatus int
roles []string
require string
}{
{"not a member", false, 0, nil, ""},
{"missing the required role", true, 0, []string{"role-1"}, "role-2"},
{"member endpoint 403", true, http.StatusForbidden, nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.member = tc.member
stub.memberStatus = tc.memberStatus
stub.roles = tc.roles
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st := newTestStore(t)
router := newRouter(st, cfg)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
}
if !strings.Contains(rr.Body.String(), "not a member of this community") {
t.Fatalf("refusal body = %q, want the clear non-member explanation", rr.Body.String())
}
if strings.Contains(rr.Body.String(), "guild-1") {
t.Fatalf("refusal body = %q, leaks the guild id", rr.Body.String())
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused sign-in set a cookie")
}
// The seed owner is still the only Reader, and no session exists.
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers = %d after a refusal, want 1", n)
}
})
}
}
// The positive role-gated path: a member holding the required role signs in.
func TestDiscordLoginRequiresRolePositive(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.roles = []string{"role-1"}
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = "role-1"
router, st := newWebTestServer(t, cfg)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (body %s)", rr.Code, rr.Body.String())
}
cookies := rr.Result().Cookies()
if len(cookies) != 1 || cookies[0].Value == "" {
t.Fatalf("cookies = %+v, want a session cookie", cookies)
}
if _, ok, err := st.GetSession(cookies[0].Value, time.Now()); err != nil || !ok {
t.Fatalf("session row: ok=%v err=%v, want ok", ok, err)
}
}
// Registration is the login: a guild member who is not the owner gets their
// own Reader on first sight, and every later sign-in reuses it rather than
// minting a second library.
func TestGuildMemberRegistersOnFirstLoginAndReusesIt(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers before any login = %d, want just the seeded owner", n)
}
first := signedInReader(t, router, st)
if first == st.OwnerID() {
t.Fatal("a non-owner member's session landed on the owner Reader")
}
readers := storeReaders(t, st)
if len(readers) != 2 {
t.Fatalf("readers after first login = %d, want 2", len(readers))
}
if readers[1].DiscordID != testOwnerID {
t.Fatalf("registered Reader's discord id = %q, want %q", readers[1].DiscordID, testOwnerID)
}
second := signedInReader(t, router, st)
if second != first {
t.Fatalf("second login landed on Reader %d, want the existing %d", second, first)
}
if n := len(storeReaders(t, st)); n != 2 {
t.Fatalf("readers after second login = %d, want 2 (no duplicate)", n)
}
}
// The seeded owner signs in through the same path: their row is found, not
// created a second time.
func TestOwnerLoginReusesTheSeededReader(t *testing.T) {
stub, stubSrv := newDiscordStub(t)
stub.ownerID = testDiscordID
cfg := testConfig()
cfg.Discord = discordConfig(stubSrv.URL)
router, st := newWebTestServer(t, cfg)
if got := signedInReader(t, router, st); got != st.OwnerID() {
t.Fatalf("owner's sign-in landed on Reader %d, want the seeded %d", got, st.OwnerID())
}
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers after the owner's login = %d, want 1 (the seed was duplicated)", n)
}
}
// A brand-new Reader's page explains how a library gets filled and offers both
// install links, and the script it serves carries their credential — not the
// owner's.
func TestNewReaderSeesEmptyLibraryAndTheirOwnScript(t *testing.T) {
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
_, stubSrv := newDiscordStub(t)
cfg := testConfig()
cfg.Discord = discordConfig(stubSrv.URL)
cfg.UserscriptPath = path
router, st := newWebTestServer(t, cfg)
cookie := signInCookie(t, router)
reader, _, err := st.GetSession(cookie.Value, time.Now())
if err != nil {
t.Fatalf("GetSession: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET / status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"Nothing here yet",
`href="/install/manga-bookmark.user.js"`,
`href="/install/novel-bookmark.user.js"`,
} {
if !strings.Contains(body, want) {
t.Errorf("empty library page lacks %q", want)
}
}
// The Readers panel is the owner's alone.
if strings.Contains(body, `id="readers"`) {
t.Error("a non-owner Reader was shown the Readers panel")
}
theirCred := readerCredential(testOwnerID)
if theirCred == ownerCredential() {
t.Fatal("test setup: the new Reader's credential collides with the owner's")
}
req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+theirCred+`"`) {
t.Fatalf("new Reader's script does not carry their own credential:\n%s", got)
}
if strings.Contains(rr.Body.String(), ownerCredential()) {
t.Fatal("new Reader's script carries the owner's credential")
}
if reader.ReaderID == st.OwnerID() {
t.Fatal("the new Reader's session points at the owner")
}
}
// Only the owner may revoke, and a revocation kills every session that Reader
// holds while leaving everyone else signed in.
func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
theirCookie := signInCookie(t, router)
theirSession, _, err := st.GetSession(theirCookie.Value, time.Now())
if err != nil {
t.Fatalf("GetSession: %v", err)
}
ownerCookie := sessionCookie(t, st)
// A non-owner cannot reach the endpoint at all: for them it does not exist.
req := httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
req.AddCookie(theirCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("non-owner revoke: status = %d, want 404", rr.Code)
}
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
t.Fatal("a non-owner's revoke attempt still killed the owner's session")
}
// The owner is not a revocable Reader: the button would sign out the browser
// making the request, so both the roster and the endpoint refuse it.
req = httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
req.AddCookie(ownerCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("owner revoking themselves: status = %d, want 404", rr.Code)
}
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
t.Fatal("the owner signed themselves out through the revoke endpoint")
}
req = httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(theirSession.ReaderID, 10)+"/revoke", nil)
req.AddCookie(ownerCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("owner revoke: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `id="readers"`) {
t.Fatalf("revoke response did not re-render the roster:\n%s", rr.Body.String())
}
// The revoked Reader's next request is rejected; the owner is untouched.
req = httptest.NewRequest(http.MethodGet, "/ui/list", nil)
req.AddCookie(theirCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("revoked session: status = %d, want 401", rr.Code)
}
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
t.Fatal("revoking another Reader took the owner's session with it")
}
}
// seedPass writes one durable pass row — the Lanes page's whole seam.
// The page renders from the database with no poller running at all (issue
// #145), so a test seeds rows instead of constructing a fake reporter.
func seedPass(t *testing.T, st *store.Store, p store.LanePass) {
t.Helper()
if err := st.RecordLanePass(p, -1); err != nil {
t.Fatalf("seed pass %s: %v", p.Site, err)
}
}
// lanesConfig returns a config with latest-chapter polling switched on, so
// the Lanes page's statusline speaks about the browser rather than about
// polling being off.
func lanesConfig() Config {
cfg := testConfig()
cfg.LatestPoll.Enabled = true
return cfg
}
// lanesBody fetches the Lanes fragment as the owner and returns the body.
func lanesBody(t *testing.T, router http.Handler, st *store.Store) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
}
return rr.Body.String()
}
// Every admin address carries the same navigation, while the roster only lives
// on its own page and the other pages keep their shells independent.
func TestAdminPagesCarrySharedNavigation(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
theirCookie := signInCookie(t, router)
ownerCookie := sessionCookie(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if strings.Contains(body, `id="readers"`) {
t.Error("the reading page still carries the roster; it belongs on /admin/readers")
}
if !strings.Contains(body, `href="/admin"`) {
t.Error("the owner's reading page offers no link to the admin page")
}
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(theirCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if strings.Contains(rr.Body.String(), `href="/admin"`) {
t.Error("a non-owner was offered the admin link")
}
for _, page := range []struct {
path string
name string
}{
{"/admin", "Overview"},
{"/admin/lanes", "Lanes"},
{"/admin/readers", "Readers"},
{"/admin/series", "Series"},
} {
t.Run(page.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, page.path, nil)
req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", page.path, rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `class="topbar-actions"`) {
t.Errorf("%s has no topbar action cluster:\n%s", page.path, body)
}
if !strings.Contains(body, `aria-label="Admin pages"`) {
t.Errorf("%s has no admin navigation:\n%s", page.path, body)
}
if strings.Count(body, `aria-current="page"`) != 1 {
t.Errorf("%s has %d active admin tabs, want 1:\n%s", page.path, strings.Count(body, `aria-current="page"`), body)
}
if !strings.Contains(body, page.name) {
t.Errorf("%s does not name its active page %q:\n%s", page.path, page.name, body)
}
if !strings.Contains(body, `href="/static/admin.css"`) {
t.Errorf("%s does not load the admin foundation stylesheet", page.path)
}
if page.name == "Lanes" && strings.Count(body, `hx-trigger="every 30s"`) != 1 {
t.Errorf("%s has %d Lane timers, want exactly 1", page.path, strings.Count(body, `hx-trigger="every 30s"`))
}
})
}
req = httptest.NewRequest(http.MethodGet, "/admin/readers", nil)
req.AddCookie(ownerCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
body = rr.Body.String()
for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
if !strings.Contains(body, want) {
t.Errorf("readers page lacks %q:\n%s", want, body)
}
}
if n := strings.Count(body, "/revoke"); n != 1 {
t.Fatalf("roster has %d revoke controls, want 1 (the owner's own row must have none):\n%s", n, body)
}
}
// Every administrative route is gated the same way, so the test walks the list
// the router registers rather than naming routes by hand: no session is 401,
// a signed-in non-owner is 404, and the address is not confirmed to either.
func TestAdminRoutesAreOwnerOnly(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
theirCookie := signInCookie(t, router)
ownerCookie := sessionCookie(t, st)
target := strconv.FormatInt(st.OwnerID(), 10)
patterns := web.AdminPatterns()
if len(patterns) == 0 {
t.Fatal("no administrative routes to test")
}
for _, pattern := range patterns {
method, path, ok := strings.Cut(pattern, " ")
if !ok {
t.Fatalf("route pattern %q has no method", pattern)
}
path = strings.Replace(path, "{id}", target, 1)
path = strings.Replace(path, "{site}", "asura", 1)
for _, tc := range []struct {
name string
cookie *http.Cookie
want int
}{
{"no session", nil, http.StatusUnauthorized},
{"non-owner", theirCookie, http.StatusNotFound},
} {
req := httptest.NewRequest(method, path, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Errorf("%s %s as %s: status = %d, want %d", method, path, tc.name, rr.Code, tc.want)
}
}
req := httptest.NewRequest(method, path, nil)
req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code == http.StatusUnauthorized {
t.Errorf("%s %s as the owner: status = 401, the gate rejects the owner", method, path)
}
}
}
// The Lane block reports what the pass log says, and marks the Lanes that
// need attention: a Site whose pages can only be read through a sidecar that
// is not there, and a Lane with Series waiting that its last pass did not
// read. Rows come from seeded database rows — no poller runs anywhere.
func TestAdminPageShowsLaneStatus(t *testing.T) {
cfg := lanesConfig()
cfg.BrowserWSURL = "ws://browser:9222"
router, st := newWebTestServer(t, cfg)
now := time.Now()
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-90 * time.Second).UnixMilli(), Due: 12, Checked: 12, GapMS: 40_000})
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 3, Checked: 3, GapMS: 60_000})
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 400, Checked: 400, GapMS: 8_000})
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 7, Checked: 0, GapMS: 60_000})
body := lanesBody(t, router, st)
for _, want := range []string{"asura", "kagane", "demonic", "comix", "40s", "ran 1m30s ago", "not checking", "none observed", "reachable"} {
if !strings.Contains(body, want) {
t.Errorf("lane status lacks %q:\n%s", want, body)
}
}
// Nothing is refusing and the sidecar is up, so neither mark may appear:
// a mark the owner cannot act on is worse than none.
for _, unwanted := range []string{"refusing", "no browser"} {
if strings.Contains(body, unwanted) {
t.Errorf("lane status marks %q on a healthy run:\n%s", unwanted, body)
}
}
}
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
// Series due with none checked is the design working, not a stopped Lane. The
// pass row records it with the asleep skip, and the page must render its
// sleeping sentence with no stall mark and no attention.
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
cfg := lanesConfig()
cfg.BrowserWSURL = "ws://browser:9222"
router, st := newWebTestServer(t, cfg)
seedPass(t, st, store.LanePass{
Site: "kagane", RanAt: time.Now().UnixMilli(),
Skip: latest.SkipAsleep, Due: 1, Checked: 0, GapMS: 10_000,
})
body := lanesBody(t, router, st)
if strings.Contains(body, "not checking") {
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
}
if !strings.Contains(body, "browser asleep") {
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
}
if strings.Contains(body, `class="trow attention"`) {
t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body)
}
}
// A Lane whose pass never reached a figure must not have that figure drawn as
// a zero: a refusing Lane that has never gathered figures draws "—" for the
// gap rather than stating a zero it did not measure.
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
cfg := lanesConfig()
cfg.BrowserWSURL = "ws://browser:9222"
st, dsn := newTestStoreURL(t)
router := newRouter(st, cfg)
now := time.Now()
oldNow := now.Add(-time.Minute).UnixMilli()
seedPass(t, st, store.LanePass{Site: "comix", RanAt: oldNow, Skip: latest.SkipRefusing})
// Refusal expiry lives on the Lane (poll_lanes), not the pass row, so it
// must be seeded there for the backs-off-until clause to render.
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO poll_lanes (site, refuse_until) VALUES ($1, $2)`, "comix", now.Add(10*time.Minute).UnixMilli()); err != nil {
t.Fatalf("seed lane refusal: %v", err)
}
body := lanesBody(t, router, st)
if strings.Contains(body, `>0s<`) {
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
}
if !strings.Contains(body, "refusing · backs off until") {
t.Errorf("a refusing Lane is not marked with its backoff time:\n%s", body)
}
}
// Polling switched off and a browser not configured are different facts, and
// the page must not blame the sidecar when nothing polls. Neither is a poller
// running — the Lanes page answers entirely from config and the pass log.
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
for _, tc := range []struct {
name string
cfg Config
want, unwant string
}{
{"polling switched off", testConfig(), `Polling: <span class="mark-faint">off</span>`, "not configured"},
{"browser not configured", lanesConfig(), "not configured", "Polling is switched off"},
} {
t.Run(tc.name, func(t *testing.T) {
router, st := newWebTestServer(t, tc.cfg)
body := lanesBody(t, router, st)
if !strings.Contains(body, "No data yet") {
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
}
if !strings.Contains(body, tc.want) {
t.Errorf("admin page lacks %q:\n%s", tc.want, body)
}
if strings.Contains(body, tc.unwant) {
t.Errorf("admin page states %q, which is not what is wrong:\n%s", tc.unwant, body)
}
})
}
}
// Restart survival is the point of the durable lane state: rows seeded into
// poll_passes render with no poller running anywhere, complete thirty seconds
// after a deploy. This is the test that proves the in-memory path is gone.
func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
now := time.Now()
// A pass a minute ago, another three hours ago: the latest per Site wins.
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1, Checked: 1, GapMS: 10_000})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000})
body := lanesBody(t, router, st)
if !strings.Contains(body, `class="c-site">asura</span>`) {
t.Fatalf("asura row missing from a restart-read database:\n%s", body)
}
// The fragment carries its own single timer and answers the swap it asked
// for, so the page keeps refreshing against the database.
if !strings.Contains(body, `hx-get="/ui/admin/lanes"`) || !strings.Contains(body, `hx-trigger="every 30s"`) {
t.Errorf("Lanes fragment lost its self-refresh:\n%s", body)
}
}
// A skip reason is the whole difference between a Lane resting and a Lane
// stuck: a skipped pass says why it declined, and the one true stall — empty
// skip with Series due and none read — is the only thing that draws the fault.
func TestSkipReasonIsNotAStall(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
now := time.Now()
// Asleep: due but none checked, with a skip that says why — no stall, no
// attention.
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 2, Checked: 0, GapMS: 10_000})
// The true stall: reached the loop, Series waiting, none read.
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 20_000})
body := lanesBody(t, router, st)
if !strings.Contains(body, "browser asleep") {
t.Errorf("the asleep row does not say why it declined:\n%s", body)
}
if !strings.Contains(body, "not checking") {
t.Errorf("the true stall is not rendered as a fault:\n%s", body)
}
// Exactly the stall row wears attention; the asleep row never does.
if strings.Count(body, `class="trow attention"`) != 1 {
t.Errorf("attention is on %d rows, want exactly the stall:\n%s", strings.Count(body, `class="trow attention"`), body)
}
}
// The five outcome counts render named — the page never prints the word
// "failures" — and a Site with none observed says so rather than drawing a
// blank cell.
func TestNamedOutcomeChips(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
now := time.Now()
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, NoChapter: 2, Errors: 1})
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 3, Unfetchable: 4})
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli()})
body := lanesBody(t, router, st)
for _, want := range []string{"refused 5", "no chapter 2", "errors 1", "unreachable 3", "unfetchable 4"} {
if !strings.Contains(body, want) {
t.Errorf("lane chips lack %q:\n%s", want, body)
}
}
if strings.Contains(body, "failures") {
t.Errorf("the page prints the forbidden word \"failures\":\n%s", body)
}
// comix has no outcomes in the window: it must say none observed, and the
// phrase must not be blank.
if !strings.Contains(body, "none observed") {
t.Errorf("a Site with no outcomes does not say none observed:\n%s", body)
}
}
// Browser configuration is a deployment fact and reachability is derived from
// the latest browser-Site passes inside the refusal backoff — no poller in any
// of the three.
func TestBrowserConfigAndReachabilityDerived(t *testing.T) {
now := time.Now()
t.Run("not configured", func(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipNoFetcher})
body := lanesBody(t, router, st)
if !strings.Contains(body, `mark-faint">not configured`) || strings.Contains(body, "unreachable") {
t.Errorf("unset BROWSER_WS_URL must read as not configured:\n%s", body)
}
})
t.Run("unreachable from recent sidecar-down", func(t *testing.T) {
cfg := lanesConfig()
cfg.BrowserWSURL = "ws://browser:9222"
router, st := newWebTestServer(t, cfg)
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown})
body := lanesBody(t, router, st)
if !strings.Contains(body, `bad">unreachable`) {
t.Errorf("recent sidecar-down passes must read as unreachable:\n%s", body)
}
})
t.Run("reachable from clean passes", func(t *testing.T) {
cfg := lanesConfig()
cfg.BrowserWSURL = "ws://browser:9222"
router, st := newWebTestServer(t, cfg)
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Checked: 3})
body := lanesBody(t, router, st)
if !strings.Contains(body, `mark-strong">reachable`) {
t.Errorf("clean browser passes must read as reachable:\n%s", body)
}
})
t.Run("sidecar-down outside the backoff is reachable again", func(t *testing.T) {
cfg := lanesConfig()
cfg.BrowserWSURL = "ws://browser:9222"
router, st := newWebTestServer(t, cfg)
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-30 * time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown})
body := lanesBody(t, router, st)
if !strings.Contains(body, `mark-strong">reachable`) {
t.Errorf("a sidecar-down older than the backoff must read as reachable:\n%s", body)
}
})
}
// Pressing Pause writes a future expiry at the offered length and answers
// with the freshly rendered Lanes block, so the row the owner just pressed
// reads as paused with its remaining time and offers Resume — with no poller
// having run at all. The pause is a fact about the Site, never a command to
// a process (issue #147).
func TestLanePauseRoundTrip(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
// A stall-shaped pass — due but none checked, no skip — so the test
// proves the pause renders over it as paused, never as the one true
// stall.
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000})
for _, tc := range []struct{ duration, phrase string }{
{"1h", "paused · resumes in 1h"},
{"6h", "paused · resumes in 6h"},
{"24h", "paused · resumes in 24h"},
} {
t.Run(tc.duration, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause",
strings.NewReader("duration="+tc.duration))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST pause status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `id="lanes"`) {
t.Errorf("pause response is not the Lanes block:\n%s", body)
}
if !strings.Contains(body, tc.phrase) {
t.Errorf("pause response does not render %q:\n%s", tc.phrase, body)
}
if !strings.Contains(body, `hx-post="/admin/lanes/asura/resume"`) {
t.Errorf("pause response does not offer Resume for asura:\n%s", body)
}
if strings.Contains(body, `class="trow attention"`) {
t.Errorf("a paused Lane is marked for attention:\n%s", body)
}
if strings.Contains(body, "not checking") {
t.Errorf("a paused Lane reads as the one true stall:\n%s", body)
}
// The expiry is a future fact about the Site, at the offered length.
paused, _, err := st.LaneGates("asura")
if err != nil {
t.Fatalf("LaneGates: %v", err)
}
d, _ := time.ParseDuration(tc.duration)
want := time.Now().Add(d).UnixMilli()
if paused < want-time.Minute.Milliseconds() || paused > want+time.Minute.Milliseconds() {
t.Errorf("pause expiry = %d, want now+%s (%d, within a minute)", paused, tc.duration, want)
}
})
}
}
// A missing duration and any value outside the offered set are refused with
// 400 and nothing is written: a permissive parser would turn the offered set
// into "anything Go can read", and an unoffered pause is a silent outage the
// owner left behind (issue #147).
func TestLanePauseRejectsBadDurations(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()})
for _, tc := range []struct{ name, body string }{
{"missing", ""},
{"empty value", "duration="},
{"unoffered", "duration=2h"},
{"zero", "duration=0h"},
{"absurd", "duration=999h"},
{"not a duration", "duration=six"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("pause body %q status = %d, want 400", tc.body, rr.Code)
}
})
}
paused, _, err := st.LaneGates("asura")
if err != nil {
t.Fatalf("LaneGates: %v", err)
}
if paused != 0 {
t.Errorf("a rejected pause still wrote expiry %d", paused)
}
}
// The Site in the path is client-supplied, so it is checked against the
// registry before the store sees it: an unknown Site is a 400 on both action
// routes, not a write (issue #147).
func TestLaneActionsRejectUnknownSite(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
for _, tc := range []struct{ path, body string }{
{"/admin/lanes/notasite/pause", "duration=6h"},
{"/admin/lanes/notasite/resume", ""},
} {
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("POST %s status = %d, want 400", tc.path, rr.Code)
}
}
}
// Pressing Resume zeroes the pause and answers with the freshly rendered
// block: the same slot now offers the duration select and Pause, and the
// paused phrase is gone. The queue half of resume lives in the poller tests
// (issue #147).
func TestLaneResumeRoundTrip(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 2, Checked: 2, GapMS: 10_000})
if err := st.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
t.Fatalf("PauseLane: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/resume", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST resume status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `hx-post="/admin/lanes/asura/pause"`) || !strings.Contains(body, ">Pause</button>") {
t.Errorf("resume response does not offer Pause again:\n%s", body)
}
if strings.Contains(body, "Resume") || strings.Contains(body, "paused") {
t.Errorf("resume response still reads as paused:\n%s", body)
}
paused, _, err := st.LaneGates("asura")
if err != nil {
t.Fatalf("LaneGates: %v", err)
}
if paused != 0 {
t.Errorf("resume left pause expiry %d, want 0", paused)
}
}
// Form bodies on both action routes are capped the way the API path caps
// them: an oversized body is a 400, not a memory grant, and nothing is
// written (issue #147).
func TestLaneActionsCapBody(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()})
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
for _, path := range []string{"/admin/lanes/asura/pause", "/admin/lanes/asura/resume"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(big))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("oversized body on %s status = %d, want 400", path, rr.Code)
}
}
paused, _, err := st.LaneGates("asura")
if err != nil {
t.Fatalf("LaneGates: %v", err)
}
if paused != 0 {
t.Errorf("an oversized body still paused the Lane (expiry %d)", paused)
}
}
// A pause renders as paused, never as stalled: the owner's own act must not
// be reported back as a fault. The stamp lives on poll_lanes and the pass
// rows join it, so a pause seeded straight into the store — a restart, no
// poller anywhere — renders its patina phrase with no attention flag and no
// stall mark (issue #147).
func TestPausedLaneRendersAsPausedNotStalled(t *testing.T) {
router, st := newWebTestServer(t, lanesConfig())
now := time.Now()
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000})
if err := st.PauseLane("asura", now.Add(6*time.Hour).UnixMilli()); err != nil {
t.Fatalf("PauseLane: %v", err)
}
body := lanesBody(t, router, st)
if !strings.Contains(body, `class="ok">paused · resumes in 6h<`) {
t.Errorf("a paused Lane does not render the patina phrase:\n%s", body)
}
if strings.Contains(body, `class="trow attention"`) {
t.Errorf("a paused Lane is marked for attention:\n%s", body)
}
if strings.Contains(body, "not checking") {
t.Errorf("a paused Lane reads as the one true stall:\n%s", body)
}
if strings.Contains(body, `class="bad"`) {
t.Errorf("a paused Lane wears the fault accent:\n%s", body)
}
}
// A Reader past the disagreement threshold is rendered as blocked, and
// clearing their marks both zeroes the counters and lifts the block in the
// roster the response carries back.
func TestOwnerClearsReaderMarks(t *testing.T) {
st, dsn := newTestStoreURL(t)
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
// The counters are filled by issue #103; until it lands the only way to
// stand a marked Reader up is to write the columns directly.
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE readers SET sighting_agreements = 4, sighting_disagreements = 3 WHERE id = $1`, st.OwnerID()); err != nil {
t.Fatalf("mark reader: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/readers", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, "4 confirmed / 3 contradicted") {
t.Errorf("roster does not report the Reader's marks:\n%s", body)
}
if !strings.Contains(body, "deferral blocked") {
t.Errorf("a Reader at the threshold is not rendered as blocked:\n%s", body)
}
req = httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/clear-marks", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("clear marks: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body = rr.Body.String()
if !strings.Contains(body, `id="readers"`) {
t.Fatalf("clear marks did not re-render the roster:\n%s", body)
}
if !strings.Contains(body, "0 confirmed / 0 contradicted") {
t.Errorf("roster does not report the cleared counters:\n%s", body)
}
if strings.Contains(body, "deferral blocked") {
t.Errorf("a cleared Reader is still marked blocked:\n%s", body)
}
}
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, _ := newWebTestServer(t, cfg)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rr.Code)
}
if !strings.Contains(rr.Body.String(), "unavailable") {
t.Fatalf("body = %q, want the unavailable message", rr.Body.String())
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a failed sign-in set a cookie")
}
}
func TestCallbackRateLimited(t *testing.T) {
srv, _, _ := oauthWebTestServer(t)
call := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet,
"/auth/discord/callback?code=x&state=not-the-state", nil)
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 := call().Code; code != http.StatusBadRequest {
t.Fatalf("attempt %d status = %d, want 400", i+1, code)
}
}
rr := call()
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 TestLogoutDeletesSession(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
cookie := sessionCookie(t, st)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(cookie)
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)
}
// The row is gone, so the same cookie is dead on the next request.
if _, ok, _ := st.GetSession(cookie.Value, time.Now()); ok {
t.Fatal("session row still present after logout")
}
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "Continue with Discord") {
t.Fatal("GET / after logout still rendered the library")
}
}
func TestExpiredSessionRejected(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), -time.Minute)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
cookie := &http.Cookie{Name: session.CookieName, Value: sess.ID}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Continue with Discord") {
t.Fatalf("GET / with an expired session = %d, want the login page", rr.Code)
}
req = httptest.NewRequest(http.MethodGet, "/ui/list", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("GET /ui/list with an expired session = %d, want 401", rr.Code)
}
}
func TestBookmarksAPIStillBearerOnly(t *testing.T) {
cfg := testConfig()
srv, st := 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, st))
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, testConfig())
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(st.OwnerID(), b)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
return stored
}
func uiRequest(t *testing.T, st *store.Store, 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, st))
return req
}
func TestUIRoutesRequireSession(t *testing.T) {
srv, _ := newWebTestServer(t, testConfig())
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 := testConfig()
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, st, 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(st.OwnerID(), "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, st, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
back, _, _ := st.Get(st.OwnerID(), "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 := testConfig()
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, st, 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 := testConfig()
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, st, 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(st.OwnerID(), "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 := testConfig()
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, st, 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(st.OwnerID(), "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 := testConfig()
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, st, 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(st.OwnerID(), "asura:solo")
if after.LastChapterNum != 45 {
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
}
})
}
}
func TestMutationsOnMissingKey(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
cases := []struct {
name string
req *http.Request
}{
{"favorite", uiRequest(t, st, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)},
{"chapter", uiRequest(t, st, 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 := testConfig()
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, st, 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(st.OwnerID(), "asura:solo"); ok {
t.Fatal("row still present after delete")
}
}
func TestUIListFavouritesTab(t *testing.T) {
cfg := testConfig()
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, st, 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 := testConfig()
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, st, 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(st.OwnerID(), b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
}
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := testConfig()
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, st))
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, st *store.Store, tab string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/?tab="+tab, nil)
req.AddCookie(sessionCookie(t, st))
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 := testConfig()
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(st.OwnerID(), caught); err != nil {
t.Fatalf("seed %s: %v", caught.Key, err)
}
strip := stripOf(t, srv, st, "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, st, 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(st.OwnerID(), "asura:reading")
if err != nil {
t.Fatalf("Get: %v", err)
}
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
if _, err := st.Upsert(st.OwnerID(), 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, st, "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 := testConfig()
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(st.OwnerID(), b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
if got := strings.Count(stripOf(t, srv, st, "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, st *store.Store, 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, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
func TestUIStatusSetsBucket(t *testing.T) {
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
if rr := postStatus(t, srv, st, "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(st.OwnerID(), "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 := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
if rr := postStatus(t, srv, st, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
b, _, _ := st.Get(st.OwnerID(), "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, testConfig())
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 := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
before, _, _ := st.Get(st.OwnerID(), "asura:reading")
time.Sleep(2 * time.Millisecond)
if rr := postStatus(t, srv, st, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
after, _, _ := st.Get(st.OwnerID(), "asura:reading")
if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
}
}
func TestCardShowsStatusControls(t *testing.T) {
cfg := testConfig()
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, st))
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 := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, st))
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 := testConfig()
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, st, "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, st, 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 := testConfig()
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, st, 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 := testConfig()
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, st, 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 := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, st, 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 := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, st, 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 := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, st, 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")
}
}
// seriesRowSeed is one series row (and optionally its bookmarks) for the
// Series list tests. Seeded with direct SQL because the store's own surface
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
// same reason the store's admin tests seed this way.
type seriesRowSeed struct {
key string
kind string
url string
cover string // cover_address
checkedAt int64
latestNum *float64
bookmarks int // readers that hold it; 0 = orphan
raisedBy bool // a Reader's report is attributed as the raiser
}
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
// fresh readers) with the exact admin-relevant facts a test needs.
func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed) {
t.Helper()
site, seriesID, ok := strings.Cut(seed.key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", seed.key)
}
if seed.kind == "" {
seed.kind = store.KindManga
}
var latestChapter any = ""
if seed.latestNum != nil {
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
}
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
latest_checked_at, latest_chapter, latest_chapter_num)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
t.Fatalf("seed series %q: %v", seed.key, err)
}
for i := range seed.bookmarks {
var readerID int64 = st.OwnerID()
if i > 0 {
readerID = seedReader(t, st)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (reader_id, site, series_id,
last_chapter, last_chapter_num, last_chapter_url,
favorite, status, updated_at)
VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`,
readerID, site, seriesID, seed.checkedAt); err != nil {
t.Fatalf("seed bookmark %q: %v", seed.key, err)
}
}
if seed.raisedBy {
if _, err := db.Exec(
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
st.OwnerID(), site, seriesID); err != nil {
t.Fatalf("seed raised-by %q: %v", seed.key, err)
}
}
}
// seedReader mints a fresh Reader for a second bookmark, so a series can carry
// a Reader count above one.
func seedReader(t *testing.T, st *store.Store) int64 {
t.Helper()
discordID := "seed-" + strconv.FormatInt(time.Now().UnixNano(), 10)
id, err := st.EnsureReader(discordID, [32]byte{})
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
return id
}
// adminSeriesPage drives one Series list request as the owner and returns the
// rendered body, failing the test on anything but a 200.
func adminSeriesPage(t *testing.T, srv http.Handler, st *store.Store, query string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/series"+query, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series%s status = %d, want 200", query, rr.Code)
}
return rr.Body.String()
}
// The filter select reaches the store as the wire constant and the heading
// states the same total the rows render: ?filter=no_cover renders only the
// no-cover row, its option label carries its library-wide count, and an
// unknown filter value is the absent All case, never an error.
func TestSeriesListFilterWiring(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(3), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(4), bookmarks: 1})
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
if !strings.Contains(body, "Title of asura:nocover") {
t.Errorf("no_cover list misses its row:\n%s", body)
}
if strings.Contains(body, "Title of asura:healthy") || strings.Contains(body, "Title of asura:stale") {
t.Errorf("no_cover list renders a covered row:\n%s", body)
}
if !strings.Contains(body, "1 series") || !strings.Contains(body, "No cover") {
t.Errorf("no_cover heading lacks the filtered count and name:\n%s", body)
}
if !strings.Contains(body, "No cover (1)") {
t.Errorf("the filter option label lacks its count:\n%s", body)
}
if !strings.Contains(body, `<option value="no_cover" selected>`) {
t.Errorf("the no_cover option is not selected:\n%s", body)
}
// The stale option's count is cutoff-dependent: the row read passes the
// 12h boundary, and so must the aggregate that numbers the select.
if !strings.Contains(body, "Not checked in 12h (1)") {
t.Errorf("the stale option lacks its cutoff-dependent count:\n%s", body)
}
// An unknown filter value is the absent All case: both rows, no error.
body = adminSeriesPage(t, srv, st, "?filter=bogus")
if !strings.Contains(body, "Title of asura:healthy") || !strings.Contains(body, "Title of asura:nocover") {
t.Errorf("unknown filter does not render All series:\n%s", body)
}
if !strings.Contains(body, `<option value="all" selected>`) {
t.Errorf("the all option is not selected for an unknown filter:\n%s", body)
}
}
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
// kagane manga stale row renders, and every link the page emits carries the
// filter and Site so the narrowing survives in the URL.
func TestSeriesListSiteAndKindComposeWithFilter(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
stale := time.Now().Add(-24 * time.Hour).UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:want", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
if !strings.Contains(body, "Title of kagane:want") {
t.Errorf("stale+kagane+manga misses its row:\n%s", body)
}
for _, unwanted := range []string{"Title of kagane:novel", "Title of kagane:fresh", "Title of asura:stale"} {
if strings.Contains(body, unwanted) {
t.Errorf("stale+kagane+manga renders %q:\n%s", unwanted, body)
}
}
if !strings.Contains(body, "1 series") {
t.Errorf("heading lacks the narrowed count:\n%s", body)
}
// The narrowing survives in the URL: every emitted link carries the
// filter and Site, and the kind hidden input keeps it on select submits.
for _, want := range []string{
`href="/admin/series?filter=stale&amp;site=kagane"`,
`href="/admin/series?filter=stale&amp;kind=manga&amp;site=kagane"`,
`href="/admin/series?filter=stale&amp;kind=novel&amp;site=kagane"`,
`<input type="hidden" name="kind" value="manga">`,
} {
if !strings.Contains(body, want) {
t.Errorf("narrowing is lost from the URL; missing %q:\n%s", want, body)
}
}
if !strings.Contains(body, `<option value="stale" selected>`) {
t.Errorf("the stale filter is not kept selected:\n%s", body)
}
if !strings.Contains(body, `<option value="kagane" selected>`) {
t.Errorf("the kagane site is not kept selected:\n%s", body)
}
}
// More than one page of rows: page 1 and page 2 share no key, the pager range
// comes from the window total (not a second query), and a page past the end
// re-reads at page 1 rather than rendering an empty table. All 55 rows share
// a zero check stamp, so only the (site, series_id) tie-break keeps the page
// boundary stable.
func TestSeriesListPagingIsStable(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover_address,
latest_checked_at)
SELECT 'asura', 'bulk-' || g, 'Bulk ' || g, 'https://asurascans.com/comics/bulk-' || g, 'c', 0
FROM generate_series(1, 55) AS g`); err != nil {
t.Fatalf("bulk seed series: %v", err)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
SELECT $1, 'asura', 'bulk-' || g, 1000
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
t.Fatalf("bulk seed bookmarks: %v", err)
}
srv := newRouter(st, testConfig())
pageKeys := func(body string) map[string]bool {
out := map[string]bool{}
// parts[0] is the prelude before the first detail link; every later
// chunk starts with a key, so only those count.
for _, chunk := range strings.Split(body, `href="/admin/series/`)[1:] {
if i := strings.Index(chunk, `"`); i > 0 {
out[chunk[:i]] = true
}
}
return out
}
p1 := adminSeriesPage(t, srv, st, "")
p2 := adminSeriesPage(t, srv, st, "?page=2")
if !strings.Contains(p1, "1–50 of 55") {
t.Errorf("page 1 pager range wrong:\n%s", p1)
}
if !strings.Contains(p2, "51–55 of 55") {
t.Errorf("page 2 pager range wrong:\n%s", p2)
}
if !strings.Contains(p1, `href="/admin/series?page=2"`) {
t.Errorf("page 1 lacks a next link:\n%s", p1)
}
k1, k2 := pageKeys(p1), pageKeys(p2)
if len(k1) != 50 || len(k2) != 5 {
t.Fatalf("pages hold %d and %d rows, want 50 and 5", len(k1), len(k2))
}
for k := range k1 {
if k2[k] {
t.Errorf("row %q repeats across pages", k)
}
}
if len(k1)+len(k2) != 55 {
t.Errorf("%d distinct rows across pages, want 55 (a row vanished)", len(k1)+len(k2))
}
// A page past the end re-reads at page 1: the pager states the first
// page's range and the rows render rather than an empty table.
pOver := adminSeriesPage(t, srv, st, "?page=99")
if !strings.Contains(pOver, "1–50 of 55") || len(pageKeys(pOver)) != 50 {
t.Errorf("a page past the end does not re-read at page 1:\n%s", pOver)
}
}
// Page 2 of a one-page result re-reads at page 1: the store's window count
// only runs over the rows the result set carries, so an overflow page must
// not be rendered as an empty list.
func TestSeriesListPagePastEndReReadsAtPageOne(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "?page=2")
if !strings.Contains(body, "1–3 of 3") {
t.Errorf("page 2 of a one-page result does not re-read at page 1:\n%s", body)
}
for _, want := range []string{"Title of asura:a", "Title of asura:b", "Title of asura:c"} {
if !strings.Contains(body, want) {
t.Errorf("page 2 of a one-page result dropped %q:\n%s", want, body)
}
}
if strings.Contains(body, `class="empty"`) {
t.Errorf("page 2 of a one-page result renders the empty state:\n%s", body)
}
}
// A filter matching nothing renders the named empty state, still 200, and
// keeps the filter selected: an empty hygiene list reads as good news rather
// than a broken page.
func TestSeriesListEmptyStateNamesTheFilter(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
if !strings.Contains(body, "No series") {
t.Errorf("a matching-nothing filter renders no named empty state:\n%s", body)
}
if !strings.Contains(body, "No cover") {
t.Errorf("the empty state does not name the filter:\n%s", body)
}
if !strings.Contains(body, `<option value="no_cover" selected>`) {
t.Errorf("the empty filter is not kept selected:\n%s", body)
}
if strings.Contains(body, `class="tbl series"`) {
t.Errorf("an empty list still renders the table:\n%s", body)
}
}
// A hostile title is escaped, not executed: titles come from the database and
// from third-party pages, so they are attacker-controlled.
func TestSeriesListEscapesHostileTitles(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover_address, latest_checked_at)
VALUES ('asura', 'xss', '<script>alert(1)</script>', 'u', 'c', 9000)`); err != nil {
t.Fatalf("seed hostile title: %v", err)
}
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "")
if strings.Contains(body, "<script>alert") {
t.Errorf("hostile title rendered unescaped:\n%s", body)
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Errorf("hostile title is not escaped:\n%s", body)
}
}
// Rows are the two-line form, banded by class (never nth-of-type), the site
// cell colours by class rather than inline style, chips cap at two plus a +N
// tail, and no ember token appears anywhere on the page.
func TestSeriesListRowShape(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// An orphan with no URL and no cover: three chips, capped to two plus a
// tail. The two clean rows sit on either side for the banding.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := adminSeriesPage(t, srv, st, "")
// Order is least-recently-checked first, then (site, series_id): broken
// (never checked), mid (stale), fine (fresh). The middle row carries the
// band class; the marked row the attention class.
if !(strings.Index(body, "Title of asura:broken") < strings.Index(body, "Title of asura:mid") &&
strings.Index(body, "Title of asura:mid") < strings.Index(body, "Title of asura:fine")) {
t.Errorf("rows are not in checked order:\n%s", body)
}
if strings.Count(body, `class="trow"`) != 1 {
t.Errorf("expected exactly one unmarked, unbanded row:\n%s", body)
}
if strings.Count(body, `class="trow attention"`) != 1 {
t.Errorf("expected exactly one attention row:\n%s", body)
}
if strings.Count(body, `class="trow attention band"`) != 1 {
t.Errorf("expected exactly one attention band row:\n%s", body)
}
// The orphan's chips cap at two plus the +N tail.
if !strings.Contains(body, `<span class="mark">no URL</span><span class="mark">no cover</span><span class="mark mark-faint">+1</span>`) {
t.Errorf("chips do not cap at two plus a tail:\n%s", body)
}
// The site cell is a class, never the design's inline style.
if strings.Contains(body, `style="color:var(--`) {
t.Errorf("a site cell carries an inline style:\n%s", body)
}
if !strings.Contains(body, `class="c-site site-asura"`) {
t.Errorf("the site cell lacks its site class:\n%s", body)
}
// The action cell carries the Check now control on pollable rows and the
// Remove control on the orphan (#155) — a Series no Reader holds can be
// removed, so the orphan's cell is never empty. No confirm row renders
// in this batch: the confirm gate is htmx's own, not a toggled cell.
if !strings.Contains(body, `<span class="c-act">`) {
t.Errorf("the action cell is not present:\n%s", body)
}
if got := strings.Count(body, "Check now"); got != 2 {
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
}
if got := strings.Count(body, ">Remove<"); got != 1 {
t.Errorf("Remove control count = %d, want 1 (only the orphan):\n%s", got, body)
}
if strings.Contains(body, "confirm-row") {
t.Errorf("a confirm row renders in this batch:\n%s", body)
}
// No ember: the new-chapter signal stays off the admin surface. Scoped to
// the page content — the shell's brand mark legitimately wears the ember
// flame on every page, admin or not.
mainStart := strings.Index(body, `<main class="page admin-page">`)
mainEnd := strings.Index(body, `</main>`)
if mainStart < 0 || mainEnd < 0 || mainStart > mainEnd {
t.Fatalf("no page content region to check:\n%s", body)
}
if strings.Contains(body[mainStart:mainEnd], "--ember") {
t.Errorf("the page content carries an ember token:\n%s", body)
}
}
// The per-Series page renders every Series-level fact the admin read model
// holds for the key the list row already shows: title, the composite key with
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
// count. No Reader identity or progress may appear anywhere in the response.
func TestAdminSeriesDetailRendersFacts(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
// Two Readers hold the same Series, so the anonymous count is 2.
seed(t, st, store.Bookmark{
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
})
other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash")))
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
if _, err := st.Upsert(other, store.Bookmark{
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
}); err != nil {
t.Fatalf("Upsert second reader: %v", err)
}
if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil {
t.Fatalf("MarkLatestChecked: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"SP Baby",
"kagane:sp-baby · kagane · manga",
"ch 45",
"checked 2h0m",
"2 readers",
} {
if !strings.Contains(body, want) {
t.Errorf("series detail lacks %q:\n%s", want, body)
}
}
}
// A Series the poller has never read renders the never-read state — "ch —" and
// "checked never" — rather than a confident zero, and a Series no Reader holds
// renders its count as "0 readers" rather than as a blank.
func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned",
Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned",
Kind: "manga",
})
// Removing the only Bookmark orphans the Series: the row outlives it.
if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil {
t.Fatalf("Delete: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{"ch —", "checked never", "0 readers"} {
if !strings.Contains(body, want) {
t.Errorf("never-read orphan detail lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "ch 0") {
t.Errorf("a never-read Series renders chapter 0:\n%s", body)
}
}
// The meta row renders each hygiene mark exactly when the underlying fact
// holds: a Series with no page to fetch, no cover, no Reader and a chapter a
// Sighting raised carries all four, and a whole one carries none.
func TestAdminSeriesDetailRendersMarks(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "demonic:broken", Site: "demonic", SeriesID: "broken",
Title: "Broken", Kind: "manga",
})
if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
if !strings.Contains(rr.Body.String(), want) {
t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String())
}
}
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
})
if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp",
[]byte("\x00webp-bytes"), "image/webp"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil)
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
if strings.Contains(body, mark) {
t.Errorf("whole series detail carries the %q mark:\n%s", mark, body)
}
}
if !strings.Contains(body, `src="https://bookmarks.test/covers/`) {
t.Errorf("whole series detail does not render its stored cover:\n%s", body)
}
}
// The provenance line beside the chapter names the actor class behind the
// value — "machine read" for a checked Series, "correction" for the owner's
// stamp, "sighting" for a Reader-raised one — and appears nowhere in the
// rendered Series list: an actor class is context for the Series the owner is
// already looking at, never a population to sweep (#152).
func TestAdminSeriesDetailProvenanceLine(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:machine", Site: "asura", SeriesID: "machine",
Title: "Machine", SeriesURL: "https://asurascans.com/series/machine",
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
})
if err := st.MarkLatestChecked("asura", "machine", time.Now().Add(-time.Hour).UnixMilli()); err != nil {
t.Fatalf("MarkLatestChecked: %v", err)
}
seed(t, st, store.Bookmark{
Key: "asura:hand", Site: "asura", SeriesID: "hand",
Title: "Hand", SeriesURL: "https://asurascans.com/series/hand",
Kind: "manga", LatestChapter: "12", LatestChapterNum: floatPtr(12),
})
if err := st.CorrectLatestChapter("asura", "hand", 13, time.Now().UnixMilli()); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
seed(t, st, store.Bookmark{
Key: "demonic:raised", Site: "demonic", SeriesID: "raised",
Title: "Raised", SeriesURL: "https://demonicscans.org/series/raised",
Kind: "manga",
})
if err := st.RecordSighting(st.OwnerID(), "demonic", "raised", floatPtr(7), time.Now().UnixMilli()); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
for _, tc := range []struct{ key, want string }{
{"asura:machine", "machine read"},
{"asura:hand", "correction"},
{"demonic:raised", "sighting"},
} {
body := seriesDetailPage(t, router, st, tc.key)
if !strings.Contains(body, "<span>"+tc.want+"</span>") {
t.Errorf("detail %s lacks the %q provenance line:\n%s", tc.key, tc.want, body)
}
}
listBody := adminSeriesPage(t, router, st, "")
for _, word := range []string{"machine read", "correction", "sighting"} {
if strings.Contains(listBody, "<span>"+word+"</span>") {
t.Errorf("Series list carries a %q provenance line:\n%s", word, listBody)
}
}
}
// A well-formed key naming no row is a 404, and so is a key with no ":",
// an empty Site or an empty SeriesID — the detail page never answers 500 for
// an address nobody can reach.
func TestAdminSeriesDetailUnknownKey404(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
Kind: "manga",
})
for _, path := range []string{
"/admin/series/asura:no-such-row",
"/admin/series/no-colon",
"/admin/series/:empty-site",
"/admin/series/asura:",
"/admin/series/unknown-site:row",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404", path, rr.Code)
}
}
}
// A Series past the first page of its Site's read (50 rows) must still
// render: the list shows it, so its detail link may not answer 404. The row
// lookup walks the window total, not just page 1.
func TestAdminSeriesDetailBeyondFirstPage(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
for i := 0; i < 55; i++ {
seed(t, st, store.Bookmark{
Key: fmt.Sprintf("asura:s%03d", i), Site: "asura", SeriesID: fmt.Sprintf("s%03d", i),
Title: "Bulk", SeriesURL: "https://asurascans.com/series/bulk",
Kind: "manga",
})
}
// Rows order by (latest_checked_at, site, series_id), all zero stamps, so
// "zzz" lands on page 2 behind the fifty-five "s*" rows.
seed(t, st, store.Bookmark{
Key: "asura:zzz", Site: "asura", SeriesID: "zzz",
Title: "Late", SeriesURL: "https://asurascans.com/series/zzz",
Kind: "manga",
})
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:zzz", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/asura:zzz status = %d, want 200 for a page-2 row", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Late") {
t.Errorf("page-2 row did not render:\n%s", rr.Body.String())
}
}
// The title and the key line come from the database, so they must render
// escaped: a title that is markup stays markup in the response, never HTML.
func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:evil", Site: "asura", SeriesID: "evil",
Title: `<script>alert("xss")</script>`, SeriesURL: "https://asurascans.com/series/evil",
Kind: "manga",
})
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "&lt;script&gt;") {
t.Errorf("title is not escaped:\n%s", body)
}
if strings.Contains(body, "<script>") {
t.Errorf("title rendered raw:\n%s", body)
}
}
// overviewBody fetches the Overview landing page as the owner and returns the
// rendered body, failing the test on anything but a 200.
func overviewBody(t *testing.T, srv http.Handler, st *store.Store) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin status = %d, want 200", rr.Code)
}
return rr.Body.String()
}
// The verdict line has three states, never two: healthy, the count of Lanes
// whose last pass needs the owner, and — on a pass log with no rows at all —
// "no Lane has reported yet". "Nothing has happened" must never render as
// "everything is fine": the virgin state draws no figures at all, least of
// all a confident zero waiting count.
func TestOverviewVerdictThreeStates(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
now := time.Now()
t.Run("healthy", func(t *testing.T) {
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 2, Checked: 2})
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 1})
body := overviewBody(t, router, st)
if !strings.Contains(body, "all lanes healthy") {
t.Errorf("healthy verdict missing:\n%s", body)
}
if !strings.Contains(body, "<b>3</b> series waiting") {
t.Errorf("waiting figure missing from the verdict line:\n%s", body)
}
})
t.Run("lanes need a look", func(t *testing.T) {
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Skip: latest.SkipRefusing})
body := overviewBody(t, router, st)
if !strings.Contains(body, "1 lane needs a look") {
t.Errorf("attention verdict missing:\n%s", body)
}
})
t.Run("no lane has reported", func(t *testing.T) {
virgin, fresh := newWebTestServer(t, testConfig())
body := overviewBody(t, virgin, fresh)
if !strings.Contains(body, "no Lane has reported yet") {
t.Errorf("virgin verdict missing:\n%s", body)
}
if strings.Contains(body, "all lanes healthy") || strings.Contains(body, "series waiting") {
t.Errorf("virgin verdict draws confident zeroes:\n%s", body)
}
})
}
// The Overview never refreshes itself: the Lane rest is an hour, so a timer
// would re-run a cross-Series join to redraw identical rows. Only the Lanes
// block carries a refresh attribute.
func TestOverviewCarriesNoRefreshTimer(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 1, Checked: 1})
if body := overviewBody(t, router, st); strings.Contains(body, "hx-trigger") {
t.Errorf("overview carries a refresh timer:\n%s", body)
}
}
// A hygiene figure on the landing page and the heading count on the list it
// links to come from the same store predicate — the acceptance criterion most
// likely to rot. Both endpoints are exercised in one test: each figure's href
// is read off the overview and fetched, and its number must equal the count
// in the list's own heading.
func TestOverviewHygieneFigureAgreesWithListHeading(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
now := time.Now().UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: now, latestNum: floatPtr(10), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: now, latestNum: floatPtr(3), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: now - 24*3600*1000, latestNum: floatPtr(4), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
for _, filter := range []string{"no_cover", "no_series_url", "never_checked"} {
fig := regexp.MustCompile(`href="/admin/series\?filter=` + filter + `">(\d+)</a>`).FindStringSubmatch(body)
if fig == nil {
t.Fatalf("overview has no %s figure:\n%s", filter, body)
}
list := adminSeriesPage(t, srv, st, "?filter="+filter)
heading := regexp.MustCompile(`(\d+) series <span`).FindStringSubmatch(list)
if heading == nil {
t.Fatalf("%s list has no heading count:\n%s", filter, list)
}
if fig[1] != heading[1] {
t.Errorf("%s: the overview says %s, the list it links to says %s", filter, fig[1], heading[1])
}
}
}
// A measured zero is a real fact and stays on the page, but it is not a door:
// following it would land on an empty list. The zero renders as a muted
// digit, never an anchor.
func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// Every row carries a cover and a URL, so the no-cover and no-URL figures
// are measured zeroes.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
if !strings.Contains(body, `<span class="fig zero">0</span>`) {
t.Errorf("no zero figure renders as a muted digit:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=no_cover"`) {
t.Errorf("a zero no-cover figure is still a link:\n%s", body)
}
if strings.Contains(body, `href="/admin/series?filter=no_series_url"`) {
t.Errorf("a zero no-URL figure is still a link:\n%s", body)
}
}
// The waiting figure sums Due over the latest pass per Site — older passes
// for the same Site must not double-count, so the verdict reads the same
// latest-per-Site projection the Lanes page reads.
func TestOverviewWaitingSumsLatestPassPerSite(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
now := time.Now()
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 7})
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.Add(-2 * time.Hour).UnixMilli(), Due: 4})
body := overviewBody(t, router, st)
if !strings.Contains(body, "<b>11</b> series waiting") {
t.Errorf("waiting figure is not the latest pass per Site summed (7+4=11):\n%s", body)
}
if strings.Contains(body, "<b>1</b> series waiting") {
t.Errorf("an older pass for the same Site counted into waiting:\n%s", body)
}
}
// The hygiene classes overlap — one orphaned, URL-less, cover-less Series is
// three counts and one row — so no figure on the page may equal the naive sum
// of the hygiene counts: a sum over-reports and a distinct count is a number
// nothing can be done about.
func TestOverviewRendersNoAggregateProblemCount(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
// One Series with no URL, no cover, no bookmark and no check stamp: four
// hygiene filters each count it (NoURL, NoReaders, NeverChecked, NoCover).
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
stat := regexp.MustCompile(`<div class="stat"><span class="lbl">(.*?)</span>(?:<a class="fig"[^>]*>(\d+)</a>|<span class="fig zero">(\d+)</span>)</div>`)
hygieneSum := 0
for _, s := range stat.FindAllStringSubmatch(body, -1) {
switch s[1] {
case "Series", "Manga", "Novels", "Readers":
continue
}
n, _ := strconv.Atoi(s[2] + s[3])
hygieneSum += n
}
if hygieneSum != 4 {
t.Fatalf("seeded library's hygiene figures sum to %d, want 4 (one row in four overlapping filters):\n%s", hygieneSum, body)
}
figs := regexp.MustCompile(`class="fig[^"]*"[^>]*>(\d+)</`).FindAllStringSubmatch(body, -1)
if len(figs) == 0 {
t.Fatalf("no rendered figures found:\n%s", body)
}
for _, f := range figs {
if n, _ := strconv.Atoi(f[1]); n == hygieneSum {
t.Errorf("rendered figure %d equals the naive hygiene sum %d:\n%s", n, hygieneSum, body)
}
}
}
// The per-Site table is library shape only: exactly six columns, every figure
// a door to the list narrowed by both filter and Site, and the State cell
// carrying the Lane's own sentence. No Poll outcome column belongs here.
func TestOverviewPerSiteTableLinksToNarrowedLists(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
now := time.Now().UnixMilli()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "https://asurascans.com/comics/a", cover: "aaa", checkedAt: now, latestNum: floatPtr(1), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "https://asurascans.com/comics/b", checkedAt: now, latestNum: floatPtr(2), bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "demonic:dead", checkedAt: 0, bookmarks: 0})
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now, Due: 2, Checked: 2})
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
// The landed grid pins six columns; the header states them in order.
if !strings.Contains(body, `<div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>`) {
t.Errorf("per-Site table is not the six pinned columns:\n%s", body)
}
// The Site label is a door to the Site-narrowed list.
if !strings.Contains(body, `<a class="c-site site-asura" href="/admin/series?site=asura">asura</a>`) {
t.Errorf("per-Site label is not a door to its Site-narrowed list:\n%s", body)
}
// A per-Site hygiene figure carries both the filter and the Site.
if !strings.Contains(body, `href="/admin/series?filter=no_cover&amp;site=asura">1</a>`) {
t.Errorf("per-Site no-cover figure does not narrow by Site:\n%s", body)
}
if !strings.Contains(body, `href="/admin/series?filter=no_cover&amp;site=demonic">1</a>`) {
t.Errorf("per-Site no-cover figure missing for demonic:\n%s", body)
}
// The State cell carries the Lane's own sentence: a normal pass says
// nothing, a Site with no pass says so.
if !strings.Contains(body, `<span class="c-state"></span>`) {
t.Errorf("a Lane that read normally renders no state phrase:\n%s", body)
}
if !strings.Contains(body, `no pass yet`) {
t.Errorf("a Site with series but no pass row does not say so:\n%s", body)
}
// Library shape only: no Poll outcome column anywhere.
for _, col := range []string{"Due", "Checked", "Gap", "Refused", "Errors"} {
if strings.Contains(body, "<span>"+col+"</span>") {
t.Errorf("per-Site table carries an outcome column %q:\n%s", col, body)
}
}
}
// seriesDetailPage drives one Series detail request as the owner and returns
// the rendered body.
func seriesDetailPage(t *testing.T, srv http.Handler, st *store.Store, key string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code)
}
return rr.Body.String()
}
// Pressing Check now answers with freshly rendered markup showing the pending
// marker: the list row (or the detail meta) re-rendered after the stamp, so
// the figures describe the state after the press, not before (issue #146).
func TestSeriesPollRoundTrip(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:x", Site: "asura", SeriesID: "x",
Title: "Title of asura:x", SeriesURL: "https://asurascans.com/comics/x",
})
// List surface: the row anchor swaps its own row.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `class="trow`) {
t.Errorf("poll response is not the freshly rendered row:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">requested `) {
t.Errorf("poll response lacks the pending marker:\n%s", body)
}
// The press answers with the row's own band parity (hx-vals), so the swap
// keeps the zebra alternation.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader("band=1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll (banded) status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `class="trow`) || !strings.Contains(rr.Body.String(), ` band"`) {
t.Errorf("banded press answer lost the zebra parity:\n%s", rr.Body.String())
}
// Detail surface: the meta fragment carries the same marker.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
req.Header.Set("HX-Target", "detail-meta")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll (detail) status = %d, want 200", rr.Code)
}
body = rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) || !strings.Contains(body, "requested ") {
t.Errorf("detail response lacks the meta fragment with the pending marker:\n%s", body)
}
// The request is durable: the list shows the pending marker, which is the
// same row the press's answer rendered.
body = adminSeriesPage(t, router, st, "")
if !strings.Contains(body, "requested ") {
t.Errorf("the list does not show the pending marker after the press:\n%s", body)
}
}
// The Check now control is hidden on a Series with no page to fetch and on
// one no Reader holds — the owner is never offered a button that can never do
// anything — and present otherwise, on both the list row and the detail page.
func TestSeriesPollControlVisibility(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nourl", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
// List: exactly the pollable row offers the control.
body := adminSeriesPage(t, router, st, "")
if got := strings.Count(body, "Check now"); got != 1 {
t.Errorf("list offers Check now %d times, want 1 (only the pollable row):\n%s", got, body)
}
// Detail pages: the pollable row offers it, the other two do not.
for _, tc := range []struct {
key string
want bool
}{
{"asura:ok", true},
{"asura:nourl", false},
{"asura:orphan", false},
} {
body := seriesDetailPage(t, router, st, tc.key)
if got := strings.Contains(body, "Check now"); got != tc.want {
t.Errorf("%s detail offers Check now = %v, want %v", tc.key, got, tc.want)
}
}
}
// The pending marker ages and never expires: an old unanswered request still
// renders its marker with an old age, and a second press re-stamps the
// request time.
func TestSeriesPollMarkerAgesAndNeverExpires(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:x", url: "u", checkedAt: 0, bookmarks: 1})
// A five-day-old request, never checked: pending, with no expiry.
if _, err := db.Exec(
`UPDATE series SET force_poll_at = $1 WHERE site = 'asura' AND series_id = 'x'`,
time.Now().Add(-5*24*time.Hour).UnixMilli()); err != nil {
t.Fatalf("seed force stamp: %v", err)
}
router := newRouter(st, testConfig())
body := adminSeriesPage(t, router, st, "")
if !strings.Contains(body, "requested 5d ago") {
t.Errorf("old request does not render its aged marker:\n%s", body)
}
// A second press re-stamps: the marker reads fresh again.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "requested 1m ago") {
t.Errorf("re-stamp does not re-age the marker:\n%s", rr.Body.String())
}
}
// A malformed key is a 400 and an unknown key a 404 — neither is a 500, and
// neither reaches the store as an unvalidated write.
func TestSeriesPollRejectsBadKeys(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
for _, tc := range []struct {
path string
want int
}{
{"/admin/series/nocolon/poll", http.StatusBadRequest},
{"/admin/series/:x/poll", http.StatusBadRequest},
{"/admin/series/asura:/poll", http.StatusBadRequest},
{"/admin/series/asura:ghost/poll", http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want)
}
}
}
// Form bodies on the action route are capped the way the API path caps them:
// an oversized body is a 400, not a memory grant.
func TestSeriesPollCapsBody(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:x", Site: "asura", SeriesID: "x",
Title: "Title of asura:x", SeriesURL: "u",
})
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader(big))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("oversized body status = %d, want 400", rr.Code)
}
// Nothing was stamped: the list still shows no pending marker.
if body := adminSeriesPage(t, router, st, ""); strings.Contains(body, "requested ") {
t.Errorf("an oversized body still stamped the request:\n%s", body)
}
}
// The correction route validates at the boundary: a non-numeric, zero,
// negative or non-finite chapter answers 400 and never reaches the store, and
// a finite number greater than zero stores the number, the derived label and
// the stamp. The answer is the freshly rendered meta fragment, so the figures
// describe the state after the press (#149).
func TestCorrectLatestChapterRoute(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: floatPtr(3),
})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
for _, body := range []string{
"chapter=abc", "chapter=", "chapter=0", "chapter=-1", "chapter=NaN", "chapter=Inf",
} {
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("POST latest with body %q: status = %d, want 400", body, rr.Code)
}
}
// Nothing reached the store: the seeded number stands, unstamped.
var num float64
var stamp int64
if err := db.QueryRow(`
SELECT latest_chapter_num, latest_corrected_at
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&num, &stamp); err != nil {
t.Fatalf("read back: %v", err)
}
if num != 3 || stamp != 0 {
t.Fatalf("after 400s the row is num %v, stamp %d; want 3, 0", num, stamp)
}
// A good press stores the number, the derived label and the stamp, and
// answers with the meta fragment describing the state after the press.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=12.5"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST latest status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("correction answer is not the meta fragment:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">corrected `) {
t.Errorf("correction answer lacks the fresh corrected marker:\n%s", body)
}
var label string
if err := db.QueryRow(`
SELECT latest_chapter, latest_chapter_num, latest_corrected_at
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&label, &num, &stamp); err != nil {
t.Fatalf("read back: %v", err)
}
if label != "Chapter 12.5" || num != 12.5 {
t.Errorf("stored = %q, %v; want the derived label and 12.5", label, num)
}
if stamp == 0 {
t.Error("stamp = 0, want the correction stamp written")
}
}
// The detail page offers the one-input correction with the plain copy, and
// the corrected marker rides the meta line while the stamp is set — then
// disappears the moment a machine writes the number (#149).
func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`name="chapter"`,
`hx-post="/admin/series/asura:solo/latest"`,
"The next successful Poll overwrites this value.",
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "corrected ") {
t.Errorf("uncorrected detail already carries the marker:\n%s", body)
}
// The press lands the marker on the meta line.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=7"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST latest status = %d, want 200", rr.Code)
}
body = seriesDetailPage(t, router, st, "asura:solo")
if !strings.Contains(body, `<span class="mark">corrected `) {
t.Errorf("detail page lacks the corrected marker after the press:\n%s", body)
}
if !strings.Contains(body, "ch 7") {
t.Errorf("detail page does not show the corrected number:\n%s", body)
}
// A machine write (the poller's setter) kills the marker.
if err := st.SetLatestChapter("asura", "solo", "Chapter 8", 8); err != nil {
t.Fatalf("SetLatestChapter: %v", err)
}
body = seriesDetailPage(t, router, st, "asura:solo")
if strings.Contains(body, "corrected ") {
t.Errorf("marker survives a machine write:\n%s", body)
}
if !strings.Contains(body, "ch 8") {
t.Errorf("detail page does not show the machine-written number:\n%s", body)
}
}
// The series URL repair validates with the poller's own fetch gate and
// answers 400 before anything reaches the store; a URL that passes the gate
// is stored where an Upsert would have ignored it. The request performs no
// outbound fetch — no fetcher is ever constructed on this path (the web
// router has no fetcher seam at all, and the handler only calls the store),
// so "storing is not verifying" is enforced by construction (#151).
func TestSeriesURLRepairRoute(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:solo", url: "https://asurascans.com/comics/solo", checkedAt: 9000, bookmarks: 1,
})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
// A URL the gate refuses — foreign host, http scheme, host of another
// Site — answers 400 and never reaches the store.
for _, body := range []string{
"series_url=https://evil.example/solo",
"series_url=http://asurascans.com/stories/solo",
"series_url=https://kagane.to/series/solo",
"series_url=",
} {
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("POST series-url with body %q: status = %d, want 400", body, rr.Code)
}
}
capReq := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url",
strings.NewReader("series_url=https://asurascans.com/stories/"+strings.Repeat("a", 1<<17)))
capReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
capReq.AddCookie(cookie)
capRR := httptest.NewRecorder()
router.ServeHTTP(capRR, capReq)
if capRR.Code != http.StatusBadRequest {
t.Errorf("POST series-url with an oversized body: status = %d, want 400", capRR.Code)
}
var stored string
if err := db.QueryRow(`SELECT series_url FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != "https://asurascans.com/comics/solo" {
t.Fatalf("after 400s the stored URL = %q, want the seeded one untouched", stored)
}
// A URL that passes the gate lands, and the press answers with the meta
// fragment just like the other detail-page actions.
repair := "https://asurascans.com/stories/solo-renumbered"
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url",
strings.NewReader("series_url="+repair))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST series-url status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
if body := rr.Body.String(); !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("repair answer is not the meta fragment:\n%s", body)
}
if err := db.QueryRow(`SELECT series_url FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != repair {
t.Fatalf("stored URL = %q, want %q", stored, repair)
}
}
// The detail page offers the repair input prefilled with the stored address,
// states the honest limit — a Site-wide host change is a SQL migration, not a
// per-Series form — and a stored string renders back into the input escaped
// (issue #151).
func TestAdminSeriesDetailRepairForm(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:solo", url: "https://asurascans.com/stories/solo", bookmarks: 1,
})
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:evil", url: `https://asurascans.com/x"><script>alert(1)</script>`, bookmarks: 1,
})
router := newRouter(st, testConfig())
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`name="series_url"`,
`hx-post="/admin/series/asura:solo/series-url"`,
`value="https://asurascans.com/stories/solo"`,
"A Site-wide host change", "SQL migration",
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
// The stored value that is markup stays markup in the input's value
// attribute, never executable HTML.
body = seriesDetailPage(t, router, st, "asura:evil")
if strings.Contains(body, `<script>alert(1)</script>`) {
t.Errorf("repair input renders stored URL unescaped:\n%s", body)
}
if !strings.Contains(body, `value="https://asurascans.com/x&#34;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"`) {
t.Errorf("repair input does not carry the escaped stored URL:\n%s", body)
}
}
// The Remove control is offered only to the owner, and only on a Series no
// Reader holds: on the orphan's list row and on the orphan's detail page,
// nowhere else (#155).
func TestSeriesRemoveRendersOnlyOnOrphans(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
body := adminSeriesPage(t, router, st, "")
if got := strings.Count(body, ">Remove<"); got != 1 {
t.Errorf("list offers Remove %d times, want 1 (only the orphan):\n%s", got, body)
}
for _, tc := range []struct {
key string
want bool
}{
{"asura:ok", false},
{"asura:orphan", true},
} {
body := seriesDetailPage(t, router, st, tc.key)
if got := strings.Contains(body, ">Remove<"); got != tc.want {
t.Errorf("%s detail offers Remove = %v, want %v", tc.key, got, tc.want)
}
}
}
// A removal from the list answers with the removed row's fragment and the
// heading re-rendered out of band with the fresh count: the row and the
// count are one fact. The row's press carries the list's filter state, so
// the count describes the list the owner is looking at, and the HX-Reswap
// header deletes the row through the same button that swaps the refusal in.
func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", checkedAt: 9000, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:held", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:a/remove",
strings.NewReader("filter=no_readers&band=0"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("removal status = %d, want 200", rr.Code)
}
if got := rr.Header().Get("HX-Reswap"); got != "delete" {
t.Errorf("response does not ask htmx to delete the row (HX-Reswap = %q)", got)
}
body := rr.Body.String()
if !strings.Contains(body, "Title of asura:a") {
t.Errorf("answer does not carry the removed row's fragment:\n%s", body)
}
if !strings.Contains(body, `hx-swap-oob="true"`) ||
!strings.Contains(body, "1 series") || !strings.Contains(body, "No Readers") {
t.Errorf("answer does not re-render the heading out of band with the fresh count:\n%s", body)
}
// Gone from the store, gone from the list, and the heading lies no longer.
var one int
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'a'`).Scan(&one); err != sql.ErrNoRows {
t.Fatalf("series row after removal = %v, want sql.ErrNoRows", err)
}
body = adminSeriesPage(t, router, st, "?filter=no_readers")
if strings.Contains(body, "Title of asura:a") || !strings.Contains(body, "1 series") {
t.Errorf("list after removal is not the fresh view:\n%s", body)
}
}
// A removal from the detail page navigates to the No-Readers list: htmx gets
// a full navigation (HX-Redirect — a 303 would be followed by the request
// and the list page swapped into the press's target), plain clients the 303
// the ticket names, to the wire filter the orphan list actually is.
func TestRemoveFromDetailRedirectsToNoReadersList(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:gone", url: "u", checkedAt: 9000, bookmarks: 0})
// A second orphan for the htmx dialect's request, whose row must still
// exist after the first request removed its own.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:gone2", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
target := "/admin/series?filter=" + store.SeriesFilterNoReaders
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:gone/remove", nil)
req.Header.Set("HX-Target", "detail-meta")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("detail removal status = %d, want 303", rr.Code)
}
if got := rr.Header().Get("Location"); got != target {
t.Errorf("Location = %q, want %q", got, target)
}
var one int
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'gone'`).Scan(&one); err != sql.ErrNoRows {
t.Fatalf("series row after detail removal = %v, want sql.ErrNoRows", err)
}
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:gone2/remove", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Target", "detail-meta")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if got := rr.Header().Get("HX-Redirect"); got != target {
t.Errorf("HX-Redirect = %q, want %q", got, target)
}
}
// A removal that races a fresh Bookmark is a refusal, not an error: the row
// is rendered again at its new count with the fact spelled out, never a 500,
// and it must not vanish from the list — the delete never happened. The
// detail-surface refusal navigates back to the detail page, where the same
// fresh count is visible.
func TestRemoveRacedBookmarkIsRefusedNotError(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:raced", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:raced/remove",
strings.NewReader("filter=no_readers&band=0"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("refusal status = %d, want 200 (never a 500)", rr.Code)
}
if got := rr.Header().Get("HX-Reswap"); got != "" {
t.Errorf("refusal carries HX-Reswap = %q, want none (the row must stay)", got)
}
body := rr.Body.String()
if !strings.Contains(body, "a Reader has bookmarked this Series again") {
t.Errorf("refusal does not say what happened:\n%s", body)
}
if !strings.Contains(body, `class="c-rd">1</span>`) {
t.Errorf("refusal does not render the fresh count:\n%s", body)
}
var one int
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'raced'`).Scan(&one); err != nil {
t.Fatalf("series row after refusal = %v, want present", err)
}
list := adminSeriesPage(t, router, st, "")
if !strings.Contains(list, "Title of asura:raced") {
t.Fatal("row vanished from the list after a refused removal")
}
if strings.Contains(list, ">Remove<") {
t.Errorf("a held series still offers Remove:\n%s", list)
}
// The detail-surface refusal navigates back to the detail page.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:raced/remove", nil)
req.Header.Set("HX-Target", "detail-meta")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("detail refusal status = %d, want 303", rr.Code)
}
if got := rr.Header().Get("Location"); got != "/admin/series/asura:raced" {
t.Errorf("refusal Location = %q, want the detail page", got)
}
}
// The remove route trusts the same way the poll route does: a malformed key
// is a 400 and an unknown Series a 404, and an oversized body is a 400 that
// removes nothing.
func TestRemoveRejectsBadKeysAndCapsBody(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:x", Site: "asura", SeriesID: "x",
Title: "Title of asura:x", SeriesURL: "u",
})
for _, tc := range []struct {
path string
want int
}{
{"/admin/series/nocolon/remove", http.StatusBadRequest},
{"/admin/series/:x/remove", http.StatusBadRequest},
{"/admin/series/asura:/remove", http.StatusBadRequest},
{"/admin/series/asura:ghost/remove", http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want)
}
}
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/remove", strings.NewReader(big))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("oversized body status = %d, want 400", rr.Code)
}
list := adminSeriesPage(t, router, st, "")
if !strings.Contains(list, "Title of asura:x") {
t.Errorf("an oversized body still removed the row:\n%s", list)
}
}