01301805fb
Adds favorite, latest_chapter and latest_chapter_num to the bookmark record, with an idempotent ALTER TABLE migration so the already-deployed database picks them up. updated_at now moves only when a bookmark is new or last_chapter_num changes. Clients order their list by updated_at, so favoriting a series or recording a newly published chapter must not disturb that order. Upsert consequently returns the row as stored and the handler echoes that rather than the request payload, since the candidate timestamp it sends is often discarded. Scanning also tolerates NULL in the optional columns, which a database created before this code can legitimately contain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
13 KiB
Go
436 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testToken = "s3cret-token"
|
|
|
|
func testConfig() Config {
|
|
return Config{
|
|
Token: testToken,
|
|
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
|
Port: "8080",
|
|
}
|
|
}
|
|
|
|
func newTestServer(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
store, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("OpenStore: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
return newRouter(store, testConfig())
|
|
}
|
|
|
|
func auth(req *http.Request) *http.Request {
|
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
return req
|
|
}
|
|
|
|
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 " + testToken},
|
|
{"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://asuracomic.net")
|
|
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://asuracomic.net" {
|
|
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 := Bookmark{
|
|
Title: "Solo Leveling",
|
|
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
|
Cover: "https://asuracomic.net/cover.jpg",
|
|
LastChapter: "Chapter 10",
|
|
LastChapterNum: 10,
|
|
LastChapterURL: "https://asuracomic.net/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 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 []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)
|
|
}
|
|
}
|
|
|
|
// 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 Bookmark) 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 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) []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 []Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
return list
|
|
}
|
|
|
|
func floatPtr(f float64) *float64 { return &f }
|
|
|
|
// 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(Bookmark) Bookmark
|
|
wantBumped bool
|
|
}{
|
|
{
|
|
name: "unchanged progress",
|
|
mutate: func(b Bookmark) Bookmark { return b },
|
|
wantBumped: false,
|
|
},
|
|
{
|
|
name: "changed progress",
|
|
mutate: func(b Bookmark) Bookmark {
|
|
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
|
|
return b
|
|
},
|
|
wantBumped: true,
|
|
},
|
|
{
|
|
name: "favorite only",
|
|
mutate: func(b Bookmark) Bookmark {
|
|
b.Favorite = true
|
|
return b
|
|
},
|
|
wantBumped: false,
|
|
},
|
|
{
|
|
name: "latest chapter only",
|
|
mutate: func(b Bookmark) Bookmark {
|
|
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
|
|
return b
|
|
},
|
|
wantBumped: false,
|
|
},
|
|
{
|
|
name: "unrelated metadata only",
|
|
mutate: func(b Bookmark) 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, 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, 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, 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(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, 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")
|
|
}
|
|
}
|
|
|
|
// The deployed database predates favorite/latest_chapter*, and CREATE TABLE
|
|
// IF NOT EXISTS will not add them — OpenStore must migrate in place.
|
|
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
|
|
|
legacy, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open legacy db: %v", err)
|
|
}
|
|
if _, err := legacy.Exec(`
|
|
CREATE TABLE bookmarks (
|
|
key TEXT PRIMARY KEY,
|
|
site TEXT NOT NULL,
|
|
series_id TEXT NOT NULL,
|
|
title TEXT,
|
|
series_url TEXT,
|
|
cover TEXT,
|
|
last_chapter TEXT,
|
|
last_chapter_num REAL,
|
|
last_chapter_url TEXT,
|
|
updated_at INTEGER NOT NULL
|
|
)`); err != nil {
|
|
t.Fatalf("create legacy schema: %v", err)
|
|
}
|
|
if _, err := legacy.Exec(`
|
|
INSERT INTO bookmarks (key, site, series_id, title, last_chapter, last_chapter_num, updated_at)
|
|
VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 'Chapter 7', 7, 123)`); err != nil {
|
|
t.Fatalf("seed legacy row: %v", err)
|
|
}
|
|
if err := legacy.Close(); err != nil {
|
|
t.Fatalf("close legacy db: %v", err)
|
|
}
|
|
|
|
store, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("OpenStore on legacy db: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
list, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].Key != "asura:legacy" {
|
|
t.Fatalf("legacy row lost: %+v", list)
|
|
}
|
|
got := list[0]
|
|
if got.Title != "Legacy Series" || got.LastChapterNum != 7 || got.UpdatedAt != 123 {
|
|
t.Fatalf("legacy data mangled: %+v", got)
|
|
}
|
|
if got.Favorite || got.LatestChapter != "" || got.LatestChapterNum != nil {
|
|
t.Fatalf("new columns should default empty, got %+v", got)
|
|
}
|
|
|
|
// Reopening an already-migrated database must be a no-op, not an error.
|
|
store2, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("OpenStore is not idempotent: %v", err)
|
|
}
|
|
store2.Close()
|
|
}
|