feat: register any guild member as a Reader (#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 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.
This commit is contained in:
2026-08-08 20:01:42 +07:00
parent c2b47eb05b
commit b0bf6fe770
24 changed files with 774 additions and 366 deletions
+226 -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,171 @@ 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)
}
}
// 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)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
cookie := signInCookie(t, router)
reader, _, err := st.GetSession(cookie.Value, time.Now())
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if !strings.Contains(rr.Body.String(), "not the library owner") {
t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String())
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)
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused sign-in set a cookie")
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)
}
}
if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" {
t.Fatal("a refused sign-in created a session")
// 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 := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, _ := newWebTestServer(t, cfg)
@@ -514,7 +662,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 +692,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 +718,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 +739,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 +776,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 +795,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 +844,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 +870,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 +905,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 +945,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 +969,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 +990,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 +1017,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 +1043,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 +1097,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 +1154,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 +1205,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 +1236,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 +1255,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 +1269,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 +1284,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 +1300,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 +1338,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 +1357,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 +1403,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 +1434,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 +1449,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 +1480,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 +1498,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)