Spec #136: reader-facing finished label on the flat Bookmark (#160)

This commit is contained in:
2026-08-22 17:05:09 +07:00
parent 77e3f710ec
commit ea7bb97112
9 changed files with 165 additions and 6 deletions
+48 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
@@ -298,7 +299,7 @@ func TestFlatWireFieldSet(t *testing.T) {
"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,
"status": true, "kind": true, "finished": true,
}
checkFlat := func(t *testing.T, payload []byte) map[string]json.RawMessage {
t.Helper()
@@ -362,6 +363,52 @@ func TestFlatWireFieldSet(t *testing.T) {
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.
+6 -1
View File
@@ -54,6 +54,9 @@ type Bookmark struct {
// Kind is the library bucket: manga or novel. Empty on the way in means
// "no opinion" — see Upsert.
Kind string `json:"kind"`
// Finished is the owner's retirement of the Series, derived: the flag is a
// Series fact and a client cannot write it — see Upsert.
Finished bool `json:"finished"`
}
// Series is one distinct work, shared by every bookmark that tracks it. It is
@@ -230,7 +233,8 @@ var migrations embed.FS
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind`
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind,
s.finished_at > 0`
// seriesColumns is the series row in scanSeries order, used by the poller's
// due query. latest_checked_at lives only on series — see MarkLatestChecked
@@ -609,6 +613,7 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
&b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress,
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
&b.Finished,
); err != nil {
return Bookmark{}, err
}
+31
View File
@@ -934,6 +934,37 @@ func TestUpsertCreatesSeriesFromClient(t *testing.T) {
}
}
// The wire's finished flag is derived, never stored: the Upsert's explicit
// column list does not name finished_at — the same omission that protects
// cover — so a client echoing a cached flag, true or false, cannot change the
// Series' retired state (issues #157, #160).
func TestUpsertCannotWriteSeriesFinished(t *testing.T) {
store := newTestStore(t)
seedFinished(t, store, "asura:done", "https://asurascans.com/comics/asura:done")
for _, sent := range []bool{true, false} {
stored, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:done", Site: "asura", SeriesID: "done",
Finished: sent, UpdatedAt: 2000,
})
if err != nil {
t.Fatalf("Upsert(Finished: %v): %v", sent, err)
}
if !stored.Finished {
t.Fatalf("stored.Finished = false after echoing %v, want true", sent)
}
}
var finishedAt int64
if err := store.db.QueryRow(
`SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`,
"asura", "done").Scan(&finishedAt); err != nil {
t.Fatalf("read finished_at: %v", err)
}
if finishedAt != 1000 {
t.Fatalf("series.finished_at = %v, want the seeded 1000 untouched", finishedAt)
}
}
// The hook is what starts creation-time acquisition, so it must fire exactly
// once per Series — on the PUT that created it, and on no later one, whichever
// Reader sends it.
+3
View File
@@ -84,6 +84,7 @@
/* One accent per action, so a press says which lane it belongs to. All three
are held at the same weight as --brass: muted, no ember competition. */
--slate: #7fa0c0; /* archive */
--moss: #7fae86; /* finished */
--clay: #b5906f; /* set chapter */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
/* A Lane needing attention: the admin page's only accent. Verdigris — cool,
@@ -147,6 +148,7 @@
--danger-soft: #7c2c22;
--brass: #8a681c;
--slate: #3f6689;
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
--patina: #1f6f66;
@@ -548,6 +550,7 @@ button { cursor: pointer; }
.new-chapter { color: var(--ember); }
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
.state svg { width: 10px; height: 10px; }
.state.finished { color: var(--moss); }
.is-dim .meta { color: var(--mute-2); }
.is-dim .site-asura, .is-dim .site-demonic,
.is-dim .site-comix, .is-dim .site-kagane,
+4
View File
@@ -33,6 +33,10 @@
<span class="sep">/</span>
<span class="state">archived</span>
{{end}}
{{if .Finished}}
<span class="sep">/</span>
<span class="state finished">finished</span>
{{end}}
</p>
</div>
<div class="actions">