Files
mangaBookmark/backend/api_test.go
T
sulthan 8e4fa6448e Spec #136: Finished belongs to the Series — owner-owned poll gate, Lifecycle bucket dropped (#163)
Closes #136.

Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone.

## What landed

- **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted.
- **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there.
- **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`.
- **#161** — glossary and the stale Reader-count divergence note catch up.
- **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not.

## Verification

`go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge.

Reviewed-on: #163
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-22 17:31:52 +07:00

768 lines
25 KiB
Go

package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/pgtest"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
)
// testTokenKey derives every test Reader's credential; it must match the key
// newTestStoreURL seeds the owner with, or derived credentials authenticate
// nothing.
const testTokenKey = "test-token-key"
// testDiscordID is the owner row's discord_id (newTestStoreURL); the derived
// credential is a function of it.
const testDiscordID = "test-owner"
// testCoverBaseURL is the public origin cover URLs are built from, standing in
// for PUBLIC_BASE_URL.
const testCoverBaseURL = "https://bookmarks.test"
func testConfig() Config {
return Config{
TokenKey: testTokenKey,
AllowedOrigins: []string{"https://asurascans.com", "https://demonicscans.org"},
Port: "8080",
}
}
// ownerCredential is the owner's epoch-0 derived credential: the string the
// install links carry and the userscript routes authenticate.
func ownerCredential() string {
return token.Token([]byte(testTokenKey), testDiscordID, 0)
}
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
func newTestServer(t *testing.T) http.Handler {
t.Helper()
return newRouter(newTestStore(t), testConfig())
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
s, _ := newTestStoreURL(t)
return s
}
// newTestStoreURL is newTestStore plus the database URL, for tests that need
// to reach the same database directly.
func newTestStoreURL(t *testing.T) (*store.Store, string) {
t.Helper()
url := pgtest.URL(t)
s, err := store.Open(url, store.Owner{
DiscordID: testDiscordID, TokenHash: token.Hash(ownerCredential()),
}, t.TempDir(), testCoverBaseURL)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s, url
}
// auth authenticates a request as the owner Reader, whose derived credential
// is the only thing the API accepts.
func auth(req *http.Request) *http.Request {
req.Header.Set("Authorization", "Bearer "+ownerCredential())
return req
}
func floatPtr(f float64) *float64 { return &f }
// seedForCheck inserts a bookmark (and with it its series) and forces the
// series' latest_checked_at.
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
t.Helper()
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
Key: key,
Site: site,
SeriesID: seriesID,
SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
if err := s.MarkLatestChecked(site, seriesID, checkedAt); err != nil {
t.Fatalf("seed mark %q: %v", key, err)
}
}
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
t.Helper()
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
ts, err := s.LatestCheckedAt(site, seriesID)
if err != nil {
t.Fatalf("LatestCheckedAt %q: %v", key, err)
}
return ts
}
func TestHealthzNoAuth(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", rr.Code)
}
if rr.Body.String() != "ok" {
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
}
}
func TestAuthRequired(t *testing.T) {
srv := newTestServer(t)
cases := []struct {
name string
header string
}{
{"no header", ""},
{"bad token", "Bearer wrong"},
{"not bearer", "Basic " + ownerCredential()},
{"empty bearer", "Bearer "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rr.Code)
}
})
}
}
func TestAuthAccepted(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "[]\n" {
t.Fatalf("empty list body = %q, want []", got)
}
}
func TestCORSPreflight(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
req.Header.Set("Origin", "https://asurascans.com")
req.Header.Set("Access-Control-Request-Method", "PUT")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rr.Code)
}
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asurascans.com" {
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
}
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("Allow-Methods missing")
}
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
t.Fatal("Allow-Headers missing")
}
}
func TestCORSDisallowedOrigin(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
}
}
func TestBookmarkRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "asura:solo-leveling-123"
in := store.Bookmark{
Title: "Solo Leveling",
SeriesURL: "https://asurascans.com/series/solo-leveling-123",
Cover: "https://asurascans.com/cover.jpg",
LastChapter: "Chapter 10",
LastChapterNum: 10,
LastChapterURL: "https://asurascans.com/series/solo-leveling-123/chapter/10",
}
body, _ := json.Marshal(in)
// PUT
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200", rr.Code)
}
var stored store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
t.Fatalf("derived fields wrong: %+v", stored)
}
if stored.UpdatedAt == 0 {
t.Fatal("server did not set updated_at")
}
// GET
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
var list []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
t.Fatalf("GET list wrong: %+v", list)
}
// PUT again (upsert, progress advance)
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
body, _ = json.Marshal(in)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("second PUT status = %d", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 1 || list[0].LastChapterNum != 11 {
t.Fatalf("upsert did not update in place: %+v", list)
}
// DELETE
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE status = %d, want 204", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("after delete list = %+v, want empty", list)
}
}
// The wire contract (ADR-0004): GET and PUT speak exactly the flat field set
// they always did, with the series-owned fields as siblings of the bookmark
// fields, not nested. Asserted as a key set, not by inspection.
func TestFlatWireFieldSet(t *testing.T) {
srv := newTestServer(t)
key := "comix:some-title"
in := store.Bookmark{
Key: key,
Site: "comix",
SeriesID: "some-title",
Title: "Some Title",
SeriesURL: "https://comix.to/title/some-title",
Cover: "https://comix.to/covers/some-title.jpg",
LastChapter: "Chapter 7",
LastChapterNum: 7,
LastChapterURL: "https://comix.to/title/some-title/ch/7",
Favorite: true,
LatestChapter: "Chapter 8",
LatestChapterNum: floatPtr(8),
Status: store.StatusArchived,
Kind: store.KindManga,
}
body, _ := json.Marshal(in)
wantKeys := map[string]bool{
"key": true, "site": true, "series_id": true, "title": true,
"series_url": true, "cover": true, "last_chapter": true,
"last_chapter_num": true, "last_chapter_url": true, "favorite": true,
"latest_chapter": true, "latest_chapter_num": true, "updated_at": true,
"status": true, "kind": true, "finished": true,
}
checkFlat := func(t *testing.T, payload []byte) map[string]json.RawMessage {
t.Helper()
var obj map[string]json.RawMessage
if err := json.Unmarshal(payload, &obj); err != nil {
t.Fatalf("decode: %v", err)
}
if len(obj) != len(wantKeys) {
t.Fatalf("field count = %d, want %d (%s)", len(obj), len(wantKeys), payload)
}
for k := range obj {
if !wantKeys[k] {
t.Fatalf("unexpected field %q", k)
}
}
return obj
}
// PUT
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200", rr.Code)
}
checkFlat(t, rr.Body.Bytes())
// Every field round-trips with its value, and updated_at is server-stamped.
var stored store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
latestNum := floatPtr(8)
want := store.Bookmark{
Key: key, Site: "comix", SeriesID: "some-title",
Title: in.Title, SeriesURL: in.SeriesURL,
LastChapter: in.LastChapter, LastChapterNum: in.LastChapterNum,
LastChapterURL: in.LastChapterURL, Favorite: true,
LatestChapter: in.LatestChapter, LatestChapterNum: latestNum,
Status: store.StatusArchived, Kind: store.KindManga,
}
// Cover is deliberately absent above: the client's cover is discarded, and
// this wiring acquires none, so the field is present and empty (ADR-0007).
if stored.Title != want.Title || stored.SeriesURL != want.SeriesURL || stored.Cover != want.Cover ||
stored.LastChapter != want.LastChapter || stored.LastChapterNum != want.LastChapterNum ||
stored.LastChapterURL != want.LastChapterURL || stored.Favorite != want.Favorite ||
stored.LatestChapter != want.LatestChapter ||
stored.LatestChapterNum == nil || *stored.LatestChapterNum != *want.LatestChapterNum ||
stored.Status != want.Status || stored.Kind != want.Kind {
t.Fatalf("PUT response = %+v, want %+v", stored, want)
}
if stored.UpdatedAt == 0 {
t.Fatal("updated_at not server-stamped")
}
// GET reports the same flat shape.
list := getBookmarks(t, srv)
if len(list) != 1 {
t.Fatalf("list = %d items, want 1", len(list))
}
body2, _ := json.Marshal(list[0])
checkFlat(t, body2)
}
// finished is derived on the wire and read-only: a client PUT echoing a cached
// value, forward progress or not, must not change the Series' retired state,
// so GET still reports the truth after the echo (issues #157, #160).
func TestFinishedWireRoundTrip(t *testing.T) {
s, url := newTestStoreURL(t)
srv := newRouter(s, testConfig())
key := "asura:done"
putBookmark(t, srv, key, store.Bookmark{
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/done",
LastChapterNum: 10,
})
// The store writer for the flag is the admin surface's own and lands in
// the same wave (#158), so seed the fact with SQL, like store_test.go.
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
if _, err := db.Exec(
`UPDATE series SET finished_at = 1000 WHERE site = 'asura' AND series_id = 'done'`); err != nil {
t.Fatalf("seed finished: %v", err)
}
got := getBookmarks(t, srv)
if len(got) != 1 || !got[0].Finished {
t.Fatalf("GET = %+v, want one bookmark carrying finished: true", got)
}
// A stale cache echoing the flag cannot un-finish (or finish) the Series.
for _, sent := range []bool{false, true} {
echoed := putBookmark(t, srv, key, store.Bookmark{
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/done",
LastChapterNum: 11, Finished: sent,
})
if !echoed.Finished {
t.Fatalf("PUT echoing Finished: %v reported finished = false, want true", sent)
}
got = getBookmarks(t, srv)
if !got[0].Finished {
t.Fatalf("GET after echoing Finished: %v = false, want the Series state preserved", sent)
}
}
}
// A PUT naming an existing series must ignore client-supplied title, cover and
// URL — the security boundary from ADR-0003, where a hostile site's scraped
// values could otherwise land on a shared row — while progress still lands.
func TestPutExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
srv := newTestServer(t)
key := "asura:solo"
first := putBookmark(t, srv, key, store.Bookmark{
Title: "Solo Leveling",
SeriesURL: "https://asurascans.com/comics/solo",
Cover: "https://asurascans.com/covers/solo.jpg",
LastChapterNum: 10,
})
second := putBookmark(t, srv, key, store.Bookmark{
Title: "Scraped Rename",
SeriesURL: "https://evil.example/solo",
Cover: "https://evil.example/solo.jpg",
LastChapterNum: 11,
})
if second.Title != first.Title || second.SeriesURL != first.SeriesURL || second.Cover != first.Cover {
t.Fatalf("stored = %+v, want original title/url/cover kept", second)
}
if second.LastChapterNum != 11 {
t.Fatalf("LastChapterNum = %v, want 11 — progress must still land", second.LastChapterNum)
}
}
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
// which is the row as actually stored (not the request payload).
func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark {
t.Helper()
body, _ := json.Marshal(b)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
}
var out store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return out
}
func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("GET status = %d", rr.Code)
}
var list []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
return list
}
// updated_at drives list ordering, so it must move only on a real progress
// advance — never on a favorite toggle or a latest-chapter capture.
func TestUpsertConditionalUpdatedAt(t *testing.T) {
cases := []struct {
name string
mutate func(store.Bookmark) store.Bookmark
wantBumped bool
}{
{
name: "unchanged progress",
mutate: func(b store.Bookmark) store.Bookmark { return b },
wantBumped: false,
},
{
name: "changed progress",
mutate: func(b store.Bookmark) store.Bookmark {
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
return b
},
wantBumped: true,
},
{
name: "favorite only",
mutate: func(b store.Bookmark) store.Bookmark {
b.Favorite = true
return b
},
wantBumped: false,
},
{
name: "latest chapter only",
mutate: func(b store.Bookmark) store.Bookmark {
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
return b
},
wantBumped: false,
},
{
name: "unrelated metadata only",
mutate: func(b store.Bookmark) store.Bookmark {
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
return b
},
wantBumped: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := newTestServer(t)
key := fmt.Sprintf("asura:cond-%d", i)
first := putBookmark(t, srv, key, store.Bookmark{
Title: "Test",
LastChapter: "Chapter 10",
LastChapterNum: 10,
})
if first.UpdatedAt == 0 {
t.Fatal("new bookmark did not get updated_at set")
}
// Guarantee a later wall-clock ms so a real bump is observable.
time.Sleep(2 * time.Millisecond)
second := putBookmark(t, srv, key, tc.mutate(first))
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
}
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
}
// The PUT response must match what a subsequent GET reports.
list := getBookmarks(t, srv)
if len(list) != 1 {
t.Fatalf("list = %+v, want 1 item", list)
}
if list[0].UpdatedAt != second.UpdatedAt {
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
}
})
}
}
func TestFavoriteRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "demonic:some-series"
stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true})
if !stored.Favorite {
t.Fatalf("PUT response favorite = false, want true")
}
list := getBookmarks(t, srv)
if len(list) != 1 || !list[0].Favorite {
t.Fatalf("favorite did not round-trip: %+v", list)
}
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false})
if stored.Favorite {
t.Fatal("PUT response favorite = true after unfavorite")
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].Favorite {
t.Fatalf("unfavorite did not round-trip: %+v", list)
}
}
func TestLatestChapterNullable(t *testing.T) {
srv := newTestServer(t)
key := "asura:latest-test"
// Never captured: latest_chapter_num must serialize as JSON null.
body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
}
list := getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum != nil {
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
}
// Once captured it round-trips as a value.
stored := putBookmark(t, srv, key, store.Bookmark{
Title: "No latest yet",
LatestChapter: "Chapter 162",
LatestChapterNum: floatPtr(162),
})
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
t.Fatalf("latest chapter did not round-trip: %+v", list)
}
if list[0].LatestChapter != "Chapter 162" {
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
}
}
func TestLoadConfigDiscord(t *testing.T) {
t.Setenv("DISCORD_CLIENT_ID", "client-1")
t.Setenv("DISCORD_CLIENT_SECRET", "client-secret-1")
t.Setenv("DISCORD_GUILD_ID", "guild-1")
t.Setenv("DISCORD_REQUIRED_ROLE", "role-9")
t.Setenv("DISCORD_REDIRECT_URI", "https://bm.example.com/auth/discord/callback")
t.Setenv("DISCORD_API_BASE", "https://stub.example/api")
if got := loadConfig().Discord; got.ClientID != "client-1" || got.ClientSecret != "client-secret-1" ||
got.GuildID != "guild-1" || got.RequiredRole != "role-9" ||
got.RedirectURI != "https://bm.example.com/auth/discord/callback" ||
got.APIBase != "https://stub.example/api" {
t.Fatalf("Discord config = %+v, want every field set", got)
}
// API base falls back to the Discord default; the role is optional.
t.Setenv("DISCORD_REQUIRED_ROLE", "")
t.Setenv("DISCORD_API_BASE", "")
got := loadConfig().Discord
if got.RequiredRole != "" {
t.Fatalf("RequiredRole = %q, want empty by default", got.RequiredRole)
}
if got.APIBase != "https://discord.com/api/v10" {
t.Fatalf("APIBase = %q, want the Discord default", got.APIBase)
}
}
// A userscript PUT body has no latest_checked_at field. If the column is ever
// moved into bookmarkColumns, this test catches it: the PUT would reset the
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
s := newTestStore(t)
srv := newRouter(s, testConfig())
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
// Exactly what the userscript sends: no latest_checked_at key at all.
body := `{"key":"asura:x","site":"asura","series_id":"x",
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+ownerCredential())
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 {
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
}
}
// Sighting deferral (issue #103) only reaches production through the PUT
// handler: the store and poller can be right and the feature still dead if the
// handler never records the report. Asserted where a client can see it - the
// series stops being due the moment the PUT lands.
func TestPutRecordsASighting(t *testing.T) {
s := newTestStore(t)
srv := newRouter(s, testConfig())
now := time.Now().UnixMilli()
hour := time.Hour.Milliseconds()
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", now-2*hour)
due, err := s.DueForLatestCheck("asura", now-hour, now-6*hour)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 {
t.Fatalf("due before the PUT = %d series, want 1", len(due))
}
body := `{"key":"asura:x","site":"asura","series_id":"x",
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5,
"latest_chapter":"Chapter 9","latest_chapter_num":9}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, auth(req))
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
due, err = s.DueForLatestCheck("asura", now-hour, now-6*hour)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("due after the PUT = %d series, want 0: the handler recorded no Sighting", len(due))
}
}
// The userscript route is registered outside the web UI's Discord auth, so it
// must keep working whatever the web config — see internal/userscript for the
// handler's own behaviour. The credential in the path is the owner's derived
// one, and the served script carries it substituted in.
func TestUserscriptServedWithWebUIDisabled(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)
}
s := newTestStore(t)
cfg := testConfig() // no Discord config needed for the userscript route
cfg.UserscriptPath = path
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
newRouter(s, cfg).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) {
t.Fatalf("served script does not carry the requesting Reader's credential:\n%s", got)
}
}
// Both scripts are served from the same handler, outside the web UI's auth —
// a wrong credential is a 404, never a 401.
func TestNovelUserscriptServed(t *testing.T) {
dir := t.TempDir()
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
if err := os.WriteFile(novelPath, []byte("// novel\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
s := newTestStore(t)
cfg := testConfig()
cfg.NovelUserscriptPath = novelPath
srv := newRouter(s, cfg)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/"+ownerCredential()+"/novel-bookmark.user.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
t.Fatalf("Content-Type = %q, want text/javascript", ct)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/wrong-token/novel-bookmark.user.js", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("wrong token status = %d, want 404", rr.Code)
}
}