Files
mangaBookmark/backend/main_test.go
sulthan 4229c179b0 rebrand: MangaBM → BookmarkManager, add novel library support (#15)
Two intertwined changes — the rebrand and the novel library were developed on
the same branch because the novel UI plumbing is part of the new "Bookmark
Manager" wordmark in the web shell.

## What it does

- **Rebrand**: MangaBM → BookmarkManager across the Go module, compose stack,
  env vars, Traefik hostnames, container/image names, userscript storage
  prefixes (`mangabm:cache` → `bmgr:manga:cache`, `mangabm:queue` → `bmgr:manga:queue`),
  and docs.
- **Novel library**: same backend, two libraries. New `kind` column splits
  bookmarks into `manga` / `novel`; PUT validates it. Two userscripts:
  - `manga-bookmark.user.js` — unchanged behaviour, just stamps its own `kind`.
  - `novel-bookmark.user.js` — separate Violentmonkey install with adapters
    for **novelfull.com** (polled via headless browser — Cloudflare JS
    challenge) and **lightnovelworld.net** (polled via plain TLS).
- **Web UI**: library switch on the app shell. Login art, libswitch, and
  novel-site colours from the Cinder design snapshot.

## Plumbing

- `addedColumns` ALTER for `kind` runs on first start after upgrade; every
  pre-existing row is backfilled to `'manga'`. No manual SQL, no down-time.
- `ALLOWED_ORIGINS` gains the two novel sites.
- New `NOVEL_USERSCRIPT_PATH` env (default `/userscript/novel-bookmark.user.js`),
  bindmounted alongside the manga script.
- Traefik router names `mangabm*` → `bmapi*` / `bmweb*`.

## Test status

- `go test ./...` — green
- `node --test userscript/test/logic.test.js` — 34 pass
- `node --test userscript/test/novel-logic.test.js` — 11 pass
- `node --check` on both userscripts — clean

## Notes for the redeploy

.env keys were renamed (`MANGA_API_HOST` → `BOOKMARK_API_HOST`,
`MANGA_WEB_HOST` → `BOOKMARK_WEB_HOST`). Update DNS / Traefik labels on the
prod override before pulling, otherwise the public hostnames go dark.
See the redeploy instructions I'll post next to this PR.

Reviewed-on: #15
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-06 03:58:23 +07:00

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, webConfig())
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)
}
}