Open registration to guild members (#27) (#36)

Closes #27.

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 a turned-away sign-in leaves no row
behind. `OWNER_DISCORD_ID` still seeds the owner, but only as the
administrator — it no longer gates login.

The cutover grace path goes 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. `userscript.Handler` drops its
re-derivation too — the resolved path segment is already the credential.

New surfaces: an empty library offers both install links (behind the
tab-specific empty states, so "No favourites yet" still wins), and the owner
alone gets a Readers panel with `POST /readers/{id}/revoke`. The owner's own
row is not revocable — 404, not a self-logout.

Isolation is asserted from both directions for read, modify and delete, 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.

Verified: `go test ./...` green; live smoke against a throwaway Postgres —
empty-library state in both colour branches, roster rendering, a real revoke
through the panel (target 401s next request, owner untouched), owner
self-revoke refused 404, per-Reader `/u/<cred>` and bearer auth both 200 with
404 for an unknown credential.

Reviewed-on: #36
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #36.
This commit is contained in:
2026-08-08 20:23:17 +07:00
committed by sulthan
parent c2b47eb05b
commit 2ef769d421
25 changed files with 850 additions and 367 deletions
+262 -78
View File
@@ -1,13 +1,14 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
@@ -17,20 +18,13 @@ import (
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
_ "github.com/jackc/pgx/v5/stdlib"
)
// 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"
// 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) {
@@ -137,12 +131,11 @@ func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) {
// 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,
ClientID: "client-1",
ClientSecret: "client-secret-1",
GuildID: "guild-1",
APIBase: stubURL,
RedirectURI: "https://bm.example.com/auth/discord/callback",
}
}
@@ -151,7 +144,7 @@ func discordConfig(stubURL string) web.DiscordConfig {
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
t.Helper()
stub, srv := newDiscordStub(t)
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, st := newWebTestServer(t, cfg)
return router, st, stub
@@ -190,24 +183,40 @@ func completeSignIn(t *testing.T, srv http.Handler, state string) *httptest.Resp
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 {
// storeReaders is the roster, ordered oldest first — the owner heads it.
func storeReaders(t *testing.T, st *store.Store) []store.ReaderSummary {
t.Helper()
db, err := sql.Open("pgx", url)
readers, err := st.Readers()
if err != nil {
t.Fatalf("open: %v", err)
t.Fatalf("Readers: %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 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 n
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, webConfig())
srv, _ := newWebTestServer(t, testConfig())
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
@@ -220,7 +229,7 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
}
func TestIndexWithSessionShowsList(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -397,10 +406,10 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
stub.member = tc.member
stub.memberStatus = tc.memberStatus
stub.roles = tc.roles
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st, dbURL := newTestStoreURL(t)
st := newTestStore(t)
router := newRouter(st, cfg)
rr := completeSignIn(t, router, startSignIn(t, router))
@@ -417,7 +426,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
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 {
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers = %d after a refusal, want 1", n)
}
})
@@ -428,7 +437,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
func TestDiscordLoginRequiresRolePositive(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.roles = []string{"role-1"}
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = "role-1"
router, st := newWebTestServer(t, cfg)
@@ -446,32 +455,207 @@ func TestDiscordLoginRequiresRolePositive(t *testing.T) {
}
}
func TestDiscordLoginRefusesNonOwner(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.ownerID = "someone-elses-snowflake"
cfg := webConfig()
cfg.Discord = discordConfig(srv.URL)
// Registration is the login: a guild member who is not the owner gets their
// own Reader on first sight, and every later sign-in reuses it rather than
// minting a second library.
func TestGuildMemberRegistersOnFirstLoginAndReusesIt(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers before any login = %d, want just the seeded owner", n)
}
first := signedInReader(t, router, st)
if first == st.OwnerID() {
t.Fatal("a non-owner member's session landed on the owner Reader")
}
readers := storeReaders(t, st)
if len(readers) != 2 {
t.Fatalf("readers after first login = %d, want 2", len(readers))
}
if readers[1].DiscordID != testOwnerID {
t.Fatalf("registered Reader's discord id = %q, want %q", readers[1].DiscordID, testOwnerID)
}
second := signedInReader(t, router, st)
if second != first {
t.Fatalf("second login landed on Reader %d, want the existing %d", second, first)
}
if n := len(storeReaders(t, st)); n != 2 {
t.Fatalf("readers after second login = %d, want 2 (no duplicate)", n)
}
}
// The seeded owner signs in through the same path: their row is found, not
// created a second time.
func TestOwnerLoginReusesTheSeededReader(t *testing.T) {
stub, stubSrv := newDiscordStub(t)
stub.ownerID = testDiscordID
cfg := testConfig()
cfg.Discord = discordConfig(stubSrv.URL)
router, st := newWebTestServer(t, cfg)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
if got := signedInReader(t, router, st); got != st.OwnerID() {
t.Fatalf("owner's sign-in landed on Reader %d, want the seeded %d", got, st.OwnerID())
}
if !strings.Contains(rr.Body.String(), "not the library owner") {
t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String())
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers after the owner's login = %d, want 1 (the seed was duplicated)", n)
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused sign-in set a cookie")
}
// 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)
}
if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" {
t.Fatal("a refused sign-in created a session")
_, stubSrv := newDiscordStub(t)
cfg := testConfig()
cfg.Discord = discordConfig(stubSrv.URL)
cfg.UserscriptPath = path
router, st := newWebTestServer(t, cfg)
cookie := signInCookie(t, router)
reader, _, err := st.GetSession(cookie.Value, time.Now())
if err != nil {
t.Fatalf("GetSession: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET / status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"Nothing here yet",
`href="/install/manga-bookmark.user.js"`,
`href="/install/novel-bookmark.user.js"`,
} {
if !strings.Contains(body, want) {
t.Errorf("empty library page lacks %q", want)
}
}
// The Readers panel is the owner's alone.
if strings.Contains(body, `id="readers"`) {
t.Error("a non-owner Reader was shown the Readers panel")
}
theirCred := readerCredential(testOwnerID)
if theirCred == ownerCredential() {
t.Fatal("test setup: the new Reader's credential collides with the owner's")
}
req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+theirCred+`"`) {
t.Fatalf("new Reader's script does not carry their own credential:\n%s", got)
}
if strings.Contains(rr.Body.String(), ownerCredential()) {
t.Fatal("new Reader's script carries the owner's credential")
}
if reader.ReaderID == st.OwnerID() {
t.Fatal("the new Reader's session points at the owner")
}
}
// Only the owner may revoke, and a revocation kills every session that Reader
// holds while leaving everyone else signed in.
func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
theirCookie := signInCookie(t, router)
theirSession, _, err := st.GetSession(theirCookie.Value, time.Now())
if err != nil {
t.Fatalf("GetSession: %v", err)
}
ownerCookie := sessionCookie(t, st)
// A non-owner cannot reach the endpoint at all: for them it does not exist.
req := httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
req.AddCookie(theirCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("non-owner revoke: status = %d, want 404", rr.Code)
}
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
t.Fatal("a non-owner's revoke attempt still killed the owner's session")
}
// The owner is not a revocable Reader: the button would sign out the browser
// making the request, so both the roster and the endpoint refuse it.
req = httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
req.AddCookie(ownerCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("owner revoking themselves: status = %d, want 404", rr.Code)
}
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
t.Fatal("the owner signed themselves out through the revoke endpoint")
}
req = httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(theirSession.ReaderID, 10)+"/revoke", nil)
req.AddCookie(ownerCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("owner revoke: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `id="readers"`) {
t.Fatalf("revoke response did not re-render the roster:\n%s", rr.Body.String())
}
// The revoked Reader's next request is rejected; the owner is untouched.
req = httptest.NewRequest(http.MethodGet, "/ui/list", nil)
req.AddCookie(theirCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("revoked session: status = %d, want 401", rr.Code)
}
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
t.Fatal("revoking another Reader took the owner's session with it")
}
}
// 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")
}
// Exactly one revocable row: the other Reader's. The owner's own row carries
// the same session count and no button.
if n := strings.Count(body, "/revoke"); n != 1 {
t.Fatalf("roster has %d revoke controls, want 1 (the owner's own row must have none):\n%s", n, body)
}
}
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, _ := newWebTestServer(t, cfg)
@@ -514,7 +698,7 @@ func TestCallbackRateLimited(t *testing.T) {
}
func TestLogoutDeletesSession(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
cookie := sessionCookie(t, st)
@@ -544,7 +728,7 @@ func TestLogoutDeletesSession(t *testing.T) {
}
func TestExpiredSessionRejected(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), -time.Minute)
if err != nil {
@@ -570,7 +754,7 @@ func TestExpiredSessionRejected(t *testing.T) {
}
func TestBookmarksAPIStillBearerOnly(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
// A session cookie must not grant access to the userscript's JSON API.
@@ -591,7 +775,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) {
}
func TestStaticAssetsServed(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
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))
@@ -628,7 +812,7 @@ func uiRequest(t *testing.T, st *store.Store, method, path string, form url.Valu
}
func TestUIRoutesRequireSession(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
srv, _ := newWebTestServer(t, testConfig())
cases := []struct{ method, path string }{
{http.MethodGet, "/ui/list"},
{http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
@@ -647,7 +831,7 @@ func TestUIRoutesRequireSession(t *testing.T) {
}
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -696,7 +880,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// 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()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -722,7 +906,7 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
}
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -757,7 +941,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
}
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -797,7 +981,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
}
func TestChapterOverrideRejectsBadInput(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -821,7 +1005,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
}
func TestMutationsOnMissingKey(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
cases := []struct {
name string
@@ -842,7 +1026,7 @@ func TestMutationsOnMissingKey(t *testing.T) {
}
func TestUIDeleteRemovesRow(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -869,7 +1053,7 @@ func TestUIDeleteRemovesRow(t *testing.T) {
}
func TestUIListFavouritesTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -895,7 +1079,7 @@ func TestUIListFavouritesTab(t *testing.T) {
}
func TestUIListNewTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -949,7 +1133,7 @@ func seedStatusRows(t *testing.T, st *store.Store) {
}
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1006,7 +1190,7 @@ func stripOf(t *testing.T, srv http.Handler, st *store.Store, tab string) string
// 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()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
@@ -1057,7 +1241,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
// The strip never grows past web.RecentCount, however many series are waiting.
func TestRecentStripCapped(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
for i := 0; i <= web.RecentCount; i++ {
b := store.Bookmark{
@@ -1088,7 +1272,7 @@ func postStatus(t *testing.T, srv http.Handler, st *store.Store, key, status str
}
func TestUIStatusSetsBucket(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1107,7 +1291,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
}
func TestUIStatusRejectsUnknownValue(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1121,7 +1305,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) {
}
func TestUIStatusRequiresSession(t *testing.T) {
srv, st := newWebTestServer(t, webConfig())
srv, st := newWebTestServer(t, testConfig())
seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
@@ -1136,7 +1320,7 @@ func TestUIStatusRequiresSession(t *testing.T) {
}
func TestUIStatusDoesNotReorderList(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1152,7 +1336,7 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
}
func TestCardShowsStatusControls(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1190,7 +1374,7 @@ func TestCardShowsStatusControls(t *testing.T) {
}
func TestAppRendersNewTabs(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1209,7 +1393,7 @@ func TestAppRendersNewTabs(t *testing.T) {
// 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()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
@@ -1255,7 +1439,7 @@ func seedLibraries(t *testing.T, st *store.Store) {
}
func TestLibrariesAreDisjoint(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
@@ -1286,7 +1470,7 @@ func TestLibrariesAreDisjoint(t *testing.T) {
// A row written before the kind column existed has none. It is manga.
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
@@ -1301,7 +1485,7 @@ func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
}
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
@@ -1332,7 +1516,7 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
}
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
@@ -1350,7 +1534,7 @@ func TestMangaPageKeepsUpdatedTab(t *testing.T) {
// 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()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)