1a7e130f9f
- Drop the FIFO from oauthStates: consumed states left entries behind, so an unrate-limited start/cancel cycle grew the slice without bound. Evict by oldest expiry instead — the map alone now bounds memory. - CreateSession runs INSERT + expiry sweep in one transaction. - slices.Contains replaces a hand-rolled contains; APIBase typo fixed. - Stale comments and test paths updated; login hover uses --ember-ink.
1359 lines
46 KiB
Go
1359 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/session"
|
|
"bookmarkmanager/backend/internal/store"
|
|
"bookmarkmanager/backend/internal/web"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
const testOwnerID = "owner-snowflake"
|
|
|
|
// webConfig returns a config whose web UI is usable: Discord identity is set,
|
|
// though the OAuth endpoints still need the stub URL from discordConfig.
|
|
func webConfig() Config {
|
|
cfg := testConfig()
|
|
cfg.Discord.OwnerDiscordID = testOwnerID
|
|
return cfg
|
|
}
|
|
|
|
// newWebTestServer returns the full router plus the store behind it, so tests
|
|
// can seed rows and assert on what the handlers wrote back.
|
|
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
|
t.Helper()
|
|
st := 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)
|
|
}
|
|
case strings.HasPrefix(r.URL.Path, "/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",
|
|
OwnerDiscordID: testOwnerID,
|
|
}
|
|
}
|
|
|
|
// 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 := webConfig()
|
|
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
|
|
}
|
|
|
|
// readerCount pokes the readers table directly — the refusal contract is that
|
|
// nothing was created, which the store API would not show.
|
|
func readerCount(t *testing.T, url string) int {
|
|
t.Helper()
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer db.Close()
|
|
var n int
|
|
if err := db.QueryRow(`SELECT count(*) FROM readers`).Scan(&n); err != nil {
|
|
t.Fatalf("count readers: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Continue with Discord") {
|
|
t.Fatal("GET / without a session did not render the Discord sign-in button")
|
|
}
|
|
}
|
|
|
|
func TestIndexWithSessionShowsList(t *testing.T) {
|
|
cfg := webConfig()
|
|
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 single-guild endpoint.
|
|
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] != "/guilds/guild-1/members/owner-snowflake" {
|
|
t.Fatalf("member requests = %v, want the 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 := webConfig()
|
|
cfg.Discord = discordConfig(srv.URL)
|
|
cfg.Discord.RequiredRole = tc.require
|
|
st, dbURL := newTestStoreURL(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 := readerCount(t, dbURL); 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 := webConfig()
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestDiscordLoginRefusesNonOwner(t *testing.T) {
|
|
stub, srv := newDiscordStub(t)
|
|
stub.ownerID = "someone-elses-snowflake"
|
|
cfg := webConfig()
|
|
cfg.Discord = discordConfig(srv.URL)
|
|
router, st := newWebTestServer(t, 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 the library owner") {
|
|
t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String())
|
|
}
|
|
if len(rr.Result().Cookies()) != 0 {
|
|
t.Fatal("a refused sign-in set a cookie")
|
|
}
|
|
if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" {
|
|
t.Fatal("a refused sign-in created a session")
|
|
}
|
|
}
|
|
|
|
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
|
|
stub, srv := newDiscordStub(t)
|
|
stub.tokenStatus = http.StatusInternalServerError
|
|
cfg := webConfig()
|
|
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 := webConfig()
|
|
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 := webConfig()
|
|
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 := webConfig()
|
|
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, webConfig())
|
|
for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg", "/static/login-art.png"} {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200", path, rr.Code)
|
|
}
|
|
if rr.Body.Len() == 0 {
|
|
t.Fatalf("GET %s returned an empty body", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// seed inserts one bookmark and returns it as stored.
|
|
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
|
|
t.Helper()
|
|
stored, err := st.Upsert(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, webConfig())
|
|
cases := []struct{ method, path string }{
|
|
{http.MethodGet, "/ui/list"},
|
|
{http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
|
|
{http.MethodPost, "/ui/bookmarks/asura:solo/chapter"},
|
|
{http.MethodDelete, "/ui/bookmarks/asura:solo"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rr.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
before := seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
before := seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
before := seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
// The chapter form is pre-filled with the current value, so tapping Save
|
|
// without editing resubmits the unchanged number. That must be a no-op: it
|
|
// must not clear last_chapter_url, rewrite the last_chapter display string,
|
|
// or move updated_at. The seed stores "45.0" against 45 so the display
|
|
// string differs from what the form submits back.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
|
|
t.Run("input "+bad, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
|
})
|
|
seed(t, st, store.Bookmark{
|
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
|
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapterNum: 10,
|
|
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
|
UpdatedAt: 2_000_000,
|
|
})
|
|
seed(t, st, store.Bookmark{
|
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
|
Title: "Tower of God", LastChapterNum: 5,
|
|
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
|
UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, st)
|
|
|
|
cases := []struct {
|
|
tab string
|
|
want, dontWant []string
|
|
}{
|
|
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
|
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
|
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
|
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
|
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.tab, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
|
req.AddCookie(sessionCookie(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
|
|
|
|
// A reading series that is caught up has nothing waiting, so it stays out.
|
|
caught := store.Bookmark{
|
|
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
|
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
|
|
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
|
}
|
|
if _, err := st.Upsert(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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
for i := 0; i <= web.RecentCount; i++ {
|
|
b := store.Bookmark{
|
|
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
|
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
|
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
|
|
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
|
}
|
|
if _, err := st.Upsert(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 := webConfig()
|
|
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 := webConfig()
|
|
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, webConfig())
|
|
seedStatusRows(t, st)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
|
strings.NewReader("status=archived"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, st)
|
|
|
|
before, _, _ := st.Get(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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedStatusRows(t, st)
|
|
|
|
cases := []struct {
|
|
tab string
|
|
want, dontWant []string
|
|
}{
|
|
// A series being read can be shelved or completed, not restored.
|
|
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
|
// An archived one can come back or be completed.
|
|
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
|
// A finished one can only come back.
|
|
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.tab, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
|
req.AddCookie(sessionCookie(t, 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 := webConfig()
|
|
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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
|
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
|
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
|
})
|
|
|
|
before := stripOf(t, srv, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedLibraries(t, st)
|
|
|
|
cases := []struct {
|
|
name, path, want, absent string
|
|
}{
|
|
{"manga is the default", "/ui/list?tab=all", "Solo Leveling", "A Will Eternal"},
|
|
{"novel is opt-in", "/ui/list?lib=novel&tab=all", "A Will Eternal", "Solo Leveling"},
|
|
{"unknown lib falls back to manga", "/ui/list?lib=comics&tab=all", "Solo Leveling", "A Will Eternal"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seed(t, st, store.Bookmark{
|
|
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
|
|
Title: "Legacy Series", UpdatedAt: 1_000_000,
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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 := webConfig()
|
|
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 & 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&tab=fav",
|
|
"/?lib=novel&tab=archived",
|
|
"/?lib=novel&tab=finished",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("novel page missing tab link %s", want)
|
|
}
|
|
}
|
|
if !strings.Contains(body, `class="libswitch"`) {
|
|
t.Fatal("novel page missing the library switch")
|
|
}
|
|
}
|
|
|
|
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedLibraries(t, st)
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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&tab=new") {
|
|
t.Fatal("the Updated tab must never be emitted for the novel library")
|
|
}
|
|
}
|
|
|
|
// tab=new is not offered for novels, so a hand-typed one must land on All
|
|
// rather than an empty page.
|
|
func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
|
cfg := webConfig()
|
|
srv, st := newWebTestServer(t, cfg)
|
|
seedLibraries(t, st)
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, uiRequest(t, 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")
|
|
}
|
|
}
|