feat(backend): favorite + latest-chapter fields, conditional updated_at
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>
This commit is contained in:
@@ -2,11 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testToken = "s3cret-token"
|
||||
@@ -187,3 +191,245 @@ func TestBookmarkRoundTrip(t *testing.T) {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user