b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
1514 lines
52 KiB
Go
1514 lines
52 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
"Your library is empty",
|
|
`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")
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// The owner's own page carries the roster; nobody else's does.
|
|
func TestOwnerSeesReadersPanel(t *testing.T) {
|
|
router, st, _ := oauthWebTestServer(t)
|
|
signInCookie(t, router)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie(t, st))
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, `id="readers"`) {
|
|
t.Fatal("the owner's page lacks the Readers panel")
|
|
}
|
|
if !strings.Contains(body, testOwnerID) {
|
|
t.Fatalf("the roster does not list the registered Reader:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "Revoke sessions") {
|
|
t.Fatal("the roster offers no revocation control for a signed-in Reader")
|
|
}
|
|
}
|
|
|
|
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 & 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 := 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&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")
|
|
}
|
|
}
|