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:
+262
-78
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user