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.
319 lines
9.2 KiB
Go
319 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
func TestLoadLatestPollDefaults(t *testing.T) {
|
|
for _, k := range []string{
|
|
"LATEST_CHAPTER_POLL_ENABLED", "LATEST_CHAPTER_POLL_COOLDOWN",
|
|
"LATEST_CHAPTER_POLL_INTERVAL", "LATEST_CHAPTER_POLL_STAGGER",
|
|
"LATEST_CHAPTER_POLL_BATCH",
|
|
} {
|
|
t.Setenv(k, "")
|
|
}
|
|
|
|
got := loadLatestPoll()
|
|
want := LatestPoll{
|
|
Enabled: true,
|
|
Cooldown: time.Hour,
|
|
Interval: 10 * time.Minute,
|
|
Stagger: 20 * time.Second,
|
|
Batch: 14,
|
|
}
|
|
if got != want {
|
|
t.Fatalf("loadLatestPoll() = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadLatestPollEnabledParsing(t *testing.T) {
|
|
tests := []struct {
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"", true}, {"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true},
|
|
{"0", false}, {"false", false}, {"False", false}, {"no", false}, {"off", false},
|
|
{"maybe", true}, // unparseable falls back to the default
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run("raw="+tt.raw, func(t *testing.T) {
|
|
t.Setenv("LATEST_CHAPTER_POLL_ENABLED", tt.raw)
|
|
if got := loadLatestPoll().Enabled; got != tt.want {
|
|
t.Fatalf("Enabled = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadLatestPollClampsAndFallsBack(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env map[string]string
|
|
wantFrom func(LatestPoll) any
|
|
want any
|
|
}{
|
|
{
|
|
name: "cooldown below the floor is clamped up",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "1m"},
|
|
wantFrom: func(p LatestPoll) any { return p.Cooldown },
|
|
want: 15 * time.Minute,
|
|
},
|
|
{
|
|
name: "cooldown at the floor is kept",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "15m"},
|
|
wantFrom: func(p LatestPoll) any { return p.Cooldown },
|
|
want: 15 * time.Minute,
|
|
},
|
|
{
|
|
name: "a valid override is honoured",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "5m"},
|
|
wantFrom: func(p LatestPoll) any { return p.Interval },
|
|
want: 5 * time.Minute,
|
|
},
|
|
{
|
|
name: "an unparseable duration falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "ten minutes"},
|
|
wantFrom: func(p LatestPoll) any { return p.Interval },
|
|
want: 10 * time.Minute,
|
|
},
|
|
{
|
|
name: "a zero duration falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_STAGGER": "0s"},
|
|
wantFrom: func(p LatestPoll) any { return p.Stagger },
|
|
want: 20 * time.Second,
|
|
},
|
|
{
|
|
name: "a valid batch is honoured",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "30"},
|
|
wantFrom: func(p LatestPoll) any { return p.Batch },
|
|
want: 30,
|
|
},
|
|
{
|
|
name: "a negative batch falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "-5"},
|
|
wantFrom: func(p LatestPoll) any { return p.Batch },
|
|
want: 14,
|
|
},
|
|
{
|
|
name: "a non-numeric batch falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "lots"},
|
|
wantFrom: func(p LatestPoll) any { return p.Batch },
|
|
want: 14,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
for k, v := range tt.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
if got := tt.wantFrom(loadLatestPoll()); got != tt.want {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPutStatusValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status string
|
|
want int
|
|
}{
|
|
{"empty is no opinion", "", http.StatusOK},
|
|
{"reading", "reading", http.StatusOK},
|
|
{"archived", "archived", http.StatusOK},
|
|
{"finished is web-only", "finished", http.StatusBadRequest},
|
|
{"garbage", "dropped", http.StatusBadRequest},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status)
|
|
req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(body)))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != tc.want {
|
|
t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String())
|
|
}
|
|
if tc.want != http.StatusOK {
|
|
return
|
|
}
|
|
var got store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
want := tc.status
|
|
if want == "" {
|
|
want = "reading"
|
|
}
|
|
if got.Status != want {
|
|
t.Fatalf("stored status = %q, want %q", got.Status, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A PUT that omits the status field entirely (what a userscript build
|
|
// predating the column sends) is the actual preserve path — the "" case
|
|
// above only exercises the fresh-INSERT default and never touches an
|
|
// existing bucket. This must both keep the archived bucket and still apply
|
|
// the chapter progress carried in the same request.
|
|
func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(`{"title":"Solo","status":"archived"}`))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(seed))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`))
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(req))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var got store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Status != "archived" {
|
|
t.Fatalf("stored status = %q, want %q", got.Status, "archived")
|
|
}
|
|
if got.LastChapterNum != 12 {
|
|
t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
|
|
}
|
|
}
|
|
|
|
func TestGzipCompressesTextNotFonts(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, testConfig())
|
|
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{"/static/style.css", true},
|
|
{"/static/filter.js", true},
|
|
{"/static/htmx.min.js", true},
|
|
{"/static/fonts/dm-sans-var-latin.woff2", false},
|
|
}
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200", tc.path, rr.Code)
|
|
}
|
|
got := rr.Header().Get("Content-Encoding") == "gzip"
|
|
if got != tc.want {
|
|
t.Errorf("GET %s Content-Encoding gzip = %v, want %v", tc.path, got, tc.want)
|
|
}
|
|
if got {
|
|
zr, err := gzip.NewReader(rr.Body)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: body is not gzip: %v", tc.path, err)
|
|
}
|
|
if _, err := io.ReadAll(zr); err != nil {
|
|
t.Fatalf("GET %s: gzip body did not decode: %v", tc.path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A client that does not ask still gets plain bytes.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/static/style.css", nil))
|
|
if enc := rr.Header().Get("Content-Encoding"); enc != "" {
|
|
t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc)
|
|
}
|
|
}
|
|
|
|
func TestPutKindValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
kind string
|
|
want int
|
|
}{
|
|
{"empty is no opinion", "", http.StatusOK},
|
|
{"manga", "manga", http.StatusOK},
|
|
{"novel", "novel", http.StatusOK},
|
|
{"garbage", "comic", http.StatusBadRequest},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
body := fmt.Sprintf(`{"title":"Solo","kind":%q}`, tc.kind)
|
|
req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(body)))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != tc.want {
|
|
t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String())
|
|
}
|
|
if tc.want != http.StatusOK {
|
|
return
|
|
}
|
|
var got store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
want := tc.kind
|
|
if want == "" {
|
|
want = "manga"
|
|
}
|
|
if got.Kind != want {
|
|
t.Fatalf("stored kind = %q, want %q", got.Kind, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The preserve path: a novel row re-PUT by a client that omits the field
|
|
// entirely must stay a novel and still record the progress it carried.
|
|
func TestPutOmittedKindPreservesNovelAndAppliesProgress(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
const key = "/bookmarks/lightnovelworld:a-will-eternal"
|
|
|
|
seed := auth(httptest.NewRequest(http.MethodPut, key,
|
|
strings.NewReader(`{"title":"A Will Eternal","kind":"novel","last_chapter_num":10}`)))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, seed)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("seed status = %d, want 200 (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, key,
|
|
strings.NewReader(`{"title":"A Will Eternal","last_chapter_num":11}`))))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
var got store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Kind != store.KindNovel {
|
|
t.Fatalf("Kind = %q, want novel", got.Kind)
|
|
}
|
|
if got.LastChapterNum != 11 {
|
|
t.Fatalf("LastChapterNum = %v, want 11", got.LastChapterNum)
|
|
}
|
|
}
|