90ce14aec6
Each Site runs its own poll goroutine, paced by Rest and Gap from the registry (sites.go) instead of the shared cooldown/interval/stagger/batch config. Rest is enforced by the due query's WHERE clause; the Lane sleeps its effective gap between fetches (hour/eligible, floored at 1s). Lane-local failures: two refusals stop that Site for 15m, a lost browser stops only the round's remaining browser Lanes, and cover heals moved to background goroutines so a slow CDN cannot consume a Lane's gap. The five LATEST_CHAPTER_POLL_* pace env vars are gone; only the kill switch remains.
265 lines
7.9 KiB
Go
265 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"bookmarkmanager/backend/internal/latest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
func TestLoadLatestPollDefaults(t *testing.T) {
|
|
t.Setenv("LATEST_CHAPTER_POLL_ENABLED", "")
|
|
if got := loadLatestPoll(); got != (LatestPoll{Enabled: true}) {
|
|
t.Fatalf("loadLatestPoll() = %+v, want %+v", got, LatestPoll{Enabled: true})
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigReadsCoverDirectory(t *testing.T) {
|
|
t.Setenv("COVER_DIR", "/covers")
|
|
if got := loadConfig().CoverDir; got != "/covers" {
|
|
t.Fatalf("CoverDir = %q, want /covers", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newLatestPoller wires the fetcher seams; pace lives in the registry, so
|
|
// nothing here sizes a cooldown any more.
|
|
func TestNewLatestPollerWiresFetchers(t *testing.T) {
|
|
tls := &latest.TLSFetcher{}
|
|
p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil)
|
|
if p.Fetch != tls {
|
|
t.Fatalf("Fetch not wired")
|
|
}
|
|
if p.BrowserFetch != nil {
|
|
t.Fatalf("BrowserFetch = %v, want nil for a browser-less deployment", p.BrowserFetch)
|
|
}
|
|
if p.CoverFetch != nil {
|
|
t.Fatalf("CoverFetch = %v, want nil when the browser is absent", p.CoverFetch)
|
|
}
|
|
if p.CoverBytesFetch == nil {
|
|
t.Fatalf("CoverBytesFetch = nil, want the TLS cover fetcher")
|
|
}
|
|
if p.Now == nil {
|
|
t.Fatalf("Now = nil, want the live clock")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|