Files
mangaBookmark/backend/internal/store/store_test.go
T
sulthan ba679223b2 Sightings: a Reader report defers a Poll of a solitary Series (#103) (#108)
Closes #103.

A userscript PUT already carries the Latest Chapter the Reader's own browser read off the Series page. It may now stand in for a Poll, under one restriction and one ceiling:

- **Solitary Series only** — a Series two Readers share is Polled on schedule however recently it was sighted, so one Reader's mistake can never reach another's list.
- **One rest of standing**, and a **six-rest ceiling** (`sightingCeilingRests`, counted in the Site's own Rest): however many Sightings arrive, an unpolled Series is Polled.

Both live in the due query's HAVING clause (`Store.DueForLatestCheck`) — the same place the schedule has always been decided, so no timer and no second code path can disagree with it. No new query per scheduler round.

Judgement costs no extra request. `Poller.checkOne` already compares what the Site publishes against what is stored: a lower number contradicts the Sighting (Reader and both numbers logged), the same number confirms it, a higher number is the Site publishing and clears the attribution instead. Three contradictions stop that Reader deferring — their reports still write the Latest Chapter — and twenty consecutive confirmations forgive them, as does the owner's clear-marks control from #102.

One client change was required: both userscripts skipped the PUT when the number had not moved, so the case the whole mechanism exists for — visiting a Series with nothing new — never reached the backend. `reportLatestChapter` sends it, skipping only the local write and the re-render. A numberless PUT (favourite toggle, progress from a chapter page) is no Sighting and defers nothing.

Schema: migration `0011_series_sightings.sql` adds `series.latest_sighted_at` and `series.latest_raised_by`. Trust model, thresholds, and rejected alternatives with their citations: `docs/adr/0011-sighting-deferral-trust-model.md`.

Reviewed on both axes (spec against #103, standards against the repo's rules); the blocker — attribution surviving a Poll that overtook the report — is fixed and has a test that fails without the fix.

Verification: `go test ./...` green (needs Docker), `node --test userscript/test/*.test.js` 66 pass.
Reviewed-on: #108
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 20:10:59 +07:00

1587 lines
55 KiB
Go

package store
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/pgtest"
)
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
// testOwner is the owner every test store seeds. Tests that need a second
// reader register one (see secondReader).
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
// testCoverBaseURL is the public origin every stored cover URL is built from.
const testCoverBaseURL = "https://bookmarks.test"
func newTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(pgtest.URL(t), testOwner, t.TempDir(), testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// secondReader registers an extra reader through the same path a first login
// takes, and returns its id.
func secondReader(t *testing.T, s *Store) int64 {
t.Helper()
discordID := "second-" + strconv.FormatInt(time.Now().UnixNano(), 10)
id, err := s.EnsureReader(discordID, sha256.Sum256([]byte("token-"+discordID)))
if err != nil {
t.Fatalf("register second reader: %v", err)
}
return id
}
// The migration runner runs on every start, so a second Open against a
// database it already built must be a no-op rather than a duplicate-table
// error, and must leave the rows alone.
func TestOpenIsIdempotent(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
if _, err := first.Upsert(first.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
first.Close()
second, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { second.Close() })
list, err := second.List(second.OwnerID())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Key != "asura:solo" {
t.Fatalf("rows after reopen = %+v, want only the seeded one", list)
}
}
// The hash lookup is the whole authentication path: the store resolves a
// Reader from the SHA-256 of their presented credential, and nothing else.
func TestReaderIDForTokenHash(t *testing.T) {
store := newTestStore(t)
ownerHash := sha256.Sum256([]byte("owner-token-hash"))
id, ok, err := store.ReaderIDForTokenHash(ownerHash)
if err != nil {
t.Fatalf("ReaderIDForTokenHash: %v", err)
}
if !ok || id != store.OwnerID() {
t.Fatalf("owner lookup = (%d, %v), want (%d, true)", id, ok, store.OwnerID())
}
if _, ok, err := store.ReaderIDForTokenHash(sha256.Sum256([]byte("nope"))); err != nil {
t.Fatalf("miss: %v", err)
} else if ok {
t.Fatal("unknown hash resolved to a Reader")
}
}
func TestReaderTokenInfo(t *testing.T) {
store := newTestStore(t)
discordID, epoch, err := store.ReaderTokenInfo(store.OwnerID())
if err != nil {
t.Fatalf("ReaderTokenInfo: %v", err)
}
if discordID != testOwner.DiscordID || epoch != 0 {
t.Fatalf("ReaderTokenInfo = (%q, %d), want (%q, 0)", discordID, epoch, testOwner.DiscordID)
}
}
// Rotation swaps the stored hash and bumps the epoch in one step, and the
// seed must not undo it: a restart re-runs seedOwner, which refreshes the
// epoch-0 hash only while the row has never been rotated.
func TestRotateTokenInvalidatesOldAndSurvivesRestart(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
store, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
oldHash := sha256.Sum256([]byte("owner-token-hash"))
newHash := sha256.Sum256([]byte("rotated-token-hash"))
if err := store.RotateToken(store.OwnerID(), 0, newHash); err != nil {
t.Fatalf("RotateToken: %v", err)
}
// A second rotation against the stale epoch is refused: the stored hash
// must never describe a different epoch than the column says.
if err := store.RotateToken(store.OwnerID(), 0, sha256.Sum256([]byte("third-hash"))); err == nil {
t.Fatal("stale-epoch rotation succeeded, want error")
}
if _, ok, err := store.ReaderIDForTokenHash(oldHash); err != nil {
t.Fatalf("old lookup: %v", err)
} else if ok {
t.Fatal("old hash still resolves after rotation")
}
if id, ok, err := store.ReaderIDForTokenHash(newHash); err != nil {
t.Fatalf("new lookup: %v", err)
} else if !ok || id != store.OwnerID() {
t.Fatalf("new hash resolved to (%d, %v), want owner", id, ok)
}
if _, epoch, err := store.ReaderTokenInfo(store.OwnerID()); err != nil {
t.Fatalf("ReaderTokenInfo: %v", err)
} else if epoch != 1 {
t.Fatalf("epoch = %d after rotation, want 1", epoch)
}
store.Close()
reopened, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { reopened.Close() })
if _, ok, err := reopened.ReaderIDForTokenHash(oldHash); err != nil {
t.Fatalf("old lookup after reopen: %v", err)
} else if ok {
t.Fatal("restart resurrected the pre-rotation hash")
}
if _, ok, err := reopened.ReaderIDForTokenHash(newHash); err != nil {
t.Fatalf("new lookup after reopen: %v", err)
} else if !ok {
t.Fatal("restart dropped the rotated hash")
}
}
func TestStoreGet(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
got, ok, err := store.Get(store.OwnerID(), "asura:solo")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !ok {
t.Fatal("Get ok = false, want true")
}
if got.Title != "Solo Leveling" || got.LastChapterNum != 45 {
t.Fatalf("Get = %+v, want title/chapter preserved", got)
}
}
func TestStoreGetMissing(t *testing.T) {
store := newTestStore(t)
_, ok, err := store.Get(store.OwnerID(), "asura:nope")
if err != nil {
t.Fatalf("Get missing returned error %v, want nil", err)
}
if ok {
t.Fatal("Get ok = true for missing key, want false")
}
}
func TestBookmarkHasNewChapter(t *testing.T) {
num := func(f float64) *float64 { return &f }
cases := []struct {
name string
b Bookmark
want bool
}{
{"latest ahead", Bookmark{LastChapterNum: 45, LatestChapterNum: num(47)}, true},
{"latest equal", Bookmark{LastChapterNum: 45, LatestChapterNum: num(45)}, false},
{"latest behind", Bookmark{LastChapterNum: 45, LatestChapterNum: num(44)}, false},
{"latest unknown", Bookmark{LastChapterNum: 45}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.b.HasNewChapter(); got != tc.want {
t.Fatalf("HasNewChapter() = %v, want %v", got, tc.want)
}
})
}
}
func TestBookmarkContinueURL(t *testing.T) {
cases := []struct {
name string
b Bookmark
want string
}{
{"chapter url present", Bookmark{LastChapterURL: "/ch/45", SeriesURL: "/series"}, "/ch/45"},
{"falls back to series", Bookmark{SeriesURL: "/series"}, "/series"},
{"both empty", Bookmark{}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.b.ContinueURL(); got != tc.want {
t.Fatalf("ContinueURL() = %q, want %q", got, tc.want)
}
})
}
}
// readLatestCheckedAt reads the column directly. It is deliberately absent from
// Series (see Store.MarkLatestChecked), so tests cannot assert on it any other
// way. The key splits the same way the API handler derives site/series_id.
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
t.Helper()
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
var ts int64
if err := s.db.QueryRow(
`SELECT latest_checked_at FROM series WHERE site = $1 AND series_id = $2`,
site, seriesID).Scan(&ts); err != nil {
t.Fatalf("read latest_checked_at %q: %v", key, err)
}
return ts
}
// seedForCheck inserts a bookmark (and with it its series) and forces the
// series' latest_checked_at.
func seedForCheck(t *testing.T, s *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(), 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)
}
}
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
// that predate the ceiling and are about rest, ordering or buckets instead.
const noCeiling = int64(-1)
func TestDueForLatestCheck(t *testing.T) {
const hour = int64(3600_000)
now := 10 * hour
tests := []struct {
name string
checkedAt int64
seriesURL string
wantDue bool
}{
{"never checked", 0, "https://asurascans.com/comics/a", true},
{"checked 30m ago", now - hour/2, "https://asurascans.com/comics/a", false},
{"checked exactly at cutoff", now - hour, "https://asurascans.com/comics/a", true},
{"checked 2h ago", now - 2*hour, "https://asurascans.com/comics/a", true},
{"no series url", 0, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:x", tt.seriesURL, tt.checkedAt)
due, err := s.DueForLatestCheck("asura", now-hour, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if got := len(due) == 1; got != tt.wantDue {
t.Fatalf("due=%v, want %v (got %d rows)", got, tt.wantDue, len(due))
}
})
}
}
func TestDueForLatestCheckOldestFirstAndScopedToSite(t *testing.T) {
s := newTestStore(t)
// Insert newest-checked first so a correct ORDER BY has to reverse it.
seedForCheck(t, s, "asura:c", "https://asurascans.com/comics/c", 300)
seedForCheck(t, s, "asura:b", "https://asurascans.com/comics/b", 200)
seedForCheck(t, s, "asura:a", "https://asurascans.com/comics/a", 100)
// A second Site's due series must not appear in asura's list: each Lane
// asks for one Site, and no Lane may see another's queue.
seedForCheck(t, s, "demonic:z", "https://demonicscans.org/manga/z", 0)
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 3 {
t.Fatalf("got %d rows, want 3 (all of asura's, none of demonic's)", len(due))
}
if due[0].Key() != "asura:a" || due[1].Key() != "asura:b" || due[2].Key() != "asura:c" {
t.Fatalf("got %q,%q,%q; want asura:a,asura:b,asura:c (oldest first)", due[0].Key(), due[1].Key(), due[2].Key())
}
}
func TestMarkLatestChecked(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 0)
if err := s.MarkLatestChecked("asura", "x", 4242); err != nil {
t.Fatalf("MarkLatestChecked: %v", err)
}
if got := readLatestCheckedAt(t, s, "asura:x"); got != 4242 {
t.Fatalf("latest_checked_at = %d, want 4242", got)
}
// A missing series is not an error: its bookmarks may have been deleted
// mid-fetch.
if err := s.MarkLatestChecked("asura", "gone", 1); err != nil {
t.Fatalf("MarkLatestChecked on missing series: %v", err)
}
}
// Upsert must not touch latest_checked_at. If the column ever migrates into
// the client-visible write path, this fails and the cooldown is silently dead.
func TestUpsertPreservesLatestCheckedAt(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 999)
b, ok, err := s.Get(s.OwnerID(), "asura:x")
if err != nil || !ok {
t.Fatalf("Get: %v ok=%v", err, ok)
}
b.Title = "changed"
if _, err := s.Upsert(s.OwnerID(), b); err != nil {
t.Fatalf("Upsert: %v", err)
}
if got := readLatestCheckedAt(t, s, "asura:x"); got != 999 {
t.Fatalf("latest_checked_at = %d after Upsert, want 999", got)
}
}
func TestUpsertDefaultsStatusToReading(t *testing.T) {
store := newTestStore(t)
stored, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
UpdatedAt: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.Status != StatusReading {
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
}
}
// An empty status means "no opinion" — a userscript build that predates the
// column must not reset the bucket on every progress PUT.
func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
base.Status = ""
base.LastChapterNum = 12
stored, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.Status != StatusArchived {
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
}
}
// Mirrors the poller's read-modify-write in latest.go: Get the current row,
// mutate only the latest-chapter fields, and Upsert the whole struct back.
// cur.Status comes from Get (never empty — see scanBookmark), so it must
// round-trip through Upsert unchanged rather than being reset.
func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
cur, found, err := store.Get(store.OwnerID(), base.Key)
if err != nil || !found {
t.Fatalf("Get: found=%v err=%v", found, err)
}
num := 7.0
cur.LatestChapter = "7"
cur.LatestChapterNum = &num
cur.UpdatedAt = time.Now().UnixMilli()
stored, err := store.Upsert(store.OwnerID(), cur)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.Status != StatusArchived {
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
}
}
func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
base.Status = StatusReading
stored, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.Status != StatusReading {
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
}
}
// A status change is not reading progress, so the list must not reorder.
func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
LastChapter: "45", LastChapterNum: 45,
UpdatedAt: time.Now().UnixMilli(),
}
first, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("seed: %v", err)
}
base.Status = StatusArchived
base.UpdatedAt = first.UpdatedAt + 60_000
stored, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.UpdatedAt != first.UpdatedAt {
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
}
}
// Archiving is the reason to keep polling — the point is to come back to a
// series that has moved on. A finished series has nothing left to publish.
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
store := newTestStore(t)
for _, tc := range []struct{ key, status string }{
{"asura:reading", StatusReading},
{"asura:archived", StatusArchived},
{"asura:finished", StatusFinished},
} {
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: tc.key, Site: "asura", SeriesID: strings.TrimPrefix(tc.key, "asura:"),
SeriesURL: "https://asurascans.com/comics/" + tc.key,
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
}); err != nil {
t.Fatalf("seed %s: %v", tc.key, err)
}
}
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
got := map[string]bool{}
for _, sr := range due {
got[sr.Key()] = true
}
if !got["asura:reading"] || !got["asura:archived"] {
t.Fatalf("due = %v, want reading and archived present", got)
}
if got["asura:finished"] {
t.Fatal("due included a finished series")
}
}
// The gap's denominator counts every Series the Lane will ever Poll: a
// finished Series must not make the Lane faster than it needs to be, and
// another Site's Series must not leak into this Site's count.
func TestEligibleSeriesCount(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:finished", Site: "asura", SeriesID: "finished",
SeriesURL: "https://asurascans.com/comics/finished",
Status: StatusFinished, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed finished: %v", err)
}
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
n, err := store.EligibleSeriesCount("asura")
if err != nil {
t.Fatalf("EligibleSeriesCount: %v", err)
}
if n != 2 {
t.Fatalf("eligible = %d, want 2 (finished excluded, demonic excluded)", n)
}
n, err = store.EligibleSeriesCount("demonic")
if err != nil {
t.Fatalf("EligibleSeriesCount(demonic): %v", err)
}
if n != 1 {
t.Fatalf("eligible(demonic) = %d, want 1", n)
}
}
func TestDisplayChapter(t *testing.T) {
cases := []struct {
name string
raw string
num float64
want string
}{
{"userscript label", "Chapter 250", 250, "Ch 250"},
{"abbreviated with dot", "Ch. 12", 12, "Ch 12"},
{"lowercase abbreviation", "ch 3", 3, "Ch 3"},
{"bare number from a manual edit", "45.5", 45.5, "Ch 45.5"},
{"empty falls back to the number", "", 7, "Ch 7"},
{"empty and zero", "", 0, "Ch 0"},
{"label with no number passes through", "Prologue", 0, "Prologue"},
{"title-like label keeps its words", "Chapter of the Sun", 0, "of the Sun"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := displayChapter(tc.raw, tc.num); got != tc.want {
t.Errorf("displayChapter(%q, %v) = %q, want %q", tc.raw, tc.num, got, tc.want)
}
})
}
b := Bookmark{LastChapter: "Chapter 9", LastChapterNum: 9}
if got := b.DisplayChapter(); got != "Ch 9" {
t.Errorf("DisplayChapter() = %q, want %q", got, "Ch 9")
}
if got := b.DisplayLatest(); got != "Ch 0" {
t.Errorf("DisplayLatest() with nothing captured = %q, want %q", got, "Ch 0")
}
num := 11.0
b.LatestChapter, b.LatestChapterNum = "Chapter 11", &num
if got := b.DisplayLatest(); got != "Ch 11" {
t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11")
}
}
func TestUpsertKindDefaultsToManga(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindManga {
t.Fatalf("Kind = %q, want %q", got.Kind, KindManga)
}
}
func TestUpsertKindRoundTrips(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindNovel {
t.Fatalf("Kind = %q, want %q", got.Kind, KindNovel)
}
}
// The real hazard: a client that predates the column sends no kind at all. That
// must keep the stored library, not silently demote a novel to manga.
func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: KindNovel, LastChapterNum: 10, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
got, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindNovel {
t.Fatalf("Kind = %q, want %q — an empty kind must not reset the library", got.Kind, KindNovel)
}
if got.LastChapterNum != 11 {
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
}
}
// The 0002 backfill must survive a database that already ran 0001 with real
// rows: one series row per distinct (site, series_id) carrying the moved
// columns, and the bookmark keeping the rest. That is the upgrade path for
// every deployed database, so it is exercised rather than trusted.
func TestMigration0002BackfillsExistingBookmarks(t *testing.T) {
url := pgtest.URL(t)
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { db.Close() })
// Run only 0001, as a database created before this change would have.
// migrate() normally creates the version table first; do the same here.
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
t.Fatalf("create version table: %v", err)
}
body, err := migrations.ReadFile("migrations/0001_bookmarks.sql")
if err != nil {
t.Fatalf("read 0001: %v", err)
}
if err := applyMigration(db, 1, string(body)); err != nil {
t.Fatalf("apply 0001: %v", err)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (key, site, series_id, title, series_url, cover,
last_chapter, last_chapter_num, last_chapter_url, favorite, latest_chapter,
latest_chapter_num, latest_checked_at, status, kind, updated_at)
VALUES ('asura:solo', 'asura', 'solo', 'Solo Leveling',
'https://asurascans.com/comics/solo', 'https://asurascans.com/covers/solo.jpg',
'Chapter 10', 10, 'https://asurascans.com/comics/solo/ch/10', true,
'Chapter 11', 11, 123456, 'reading', 'manga', 1000)`); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
// Bring it current through the production path: Open runs the schema to
// 0003, seeds the owner, then applies 0004 which attaches this row. 0002
// must have backfilled the series row, not lost data.
st, err := Open(url, testOwner, t.TempDir(), testCoverBaseURL)
if err != nil {
t.Fatalf("Open after migrate: %v", err)
}
defer st.Close()
var (
title string
checked int64
fav bool
lastNum float64
)
if err := db.QueryRow(`SELECT title, latest_checked_at FROM series
WHERE site = 'asura' AND series_id = 'solo'`).Scan(&title, &checked); err != nil {
t.Fatalf("series row missing after migration: %v", err)
}
if title != "Solo Leveling" || checked != 123456 {
t.Fatalf("series = (%q, %d), want backfilled title and latest_checked_at", title, checked)
}
// The key column is gone; the bookmark is read by its composite key.
if err := db.QueryRow(`SELECT favorite, last_chapter_num FROM bookmarks
WHERE reader_id = $1 AND site = 'asura' AND series_id = 'solo'`,
st.OwnerID()).Scan(&fav, &lastNum); err != nil {
t.Fatalf("bookmark row missing after migration: %v", err)
}
if !fav || lastNum != 10 {
t.Fatalf("bookmark = (%v, %v), want favorite and progress kept", fav, lastNum)
}
}
func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
db, err := sql.Open("pgx", pgtest.URL(t))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := migrate(db, readersMigration); err != nil {
t.Fatalf("migrate readers: %v", err)
}
if err := seedOwner(db, testOwner); err != nil {
t.Fatalf("seed owner: %v", err)
}
if err := migrate(db, 7); err != nil {
t.Fatalf("migrate legacy covers: %v", err)
}
if _, err := db.Exec(`
INSERT INTO covers (image_id, body, content_type)
VALUES ('legacy-image', 'legacy-bytes', 'image/jpeg')`); err != nil {
t.Fatalf("seed legacy cover: %v", err)
}
if err := migrate(db, 0); err != nil {
t.Fatalf("migrate filesystem covers: %v", err)
}
var count int
if err := db.QueryRow(`SELECT count(*) FROM covers`).Scan(&count); err != nil {
t.Fatalf("count covers: %v", err)
}
if count != 0 {
t.Fatalf("legacy covers = %d, want 0", count)
}
var bodyColumn int
if err := db.QueryRow(`SELECT count(*) FROM information_schema.columns
WHERE table_name = 'covers' AND column_name = 'body'`).Scan(&bodyColumn); err != nil {
t.Fatalf("cover columns: %v", err)
}
if bodyColumn != 0 {
t.Fatal("legacy covers table still has body column")
}
}
// readSeries reads the series row directly, for asserting on what Upsert
// actually stored rather than what the joined Bookmark reports.
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
t.Helper()
sr, err := scanSeries(s.db.QueryRow(
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
if err != nil {
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
}
return sr
}
// The first PUT for a series creates its row from the client's title and URL —
// there is no other source for them (ADR-0003). The Cover is not among them:
// it is acquired server-side, so a client-supplied one is dropped even on a
// brand-new row (ADR-0007).
func TestUpsertCreatesSeriesFromClient(t *testing.T) {
store := newTestStore(t)
stored, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
Cover: "https://asurascans.com/covers/solo.jpg", Kind: KindManga,
UpdatedAt: 1000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.Cover != "" {
t.Fatalf("Cover = %q, want empty — a client cover is never stored", stored.Cover)
}
sr := readSeries(t, store, "asura", "solo")
if sr.Title != "Solo Leveling" || sr.SeriesURL != "https://asurascans.com/comics/solo" {
t.Fatalf("series = %+v, want client title/url stored", sr)
}
if sr.Cover != "" {
t.Fatalf("series cover = %q, want empty", sr.Cover)
}
}
// 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.
func TestOnSeriesCreatedFiresOnceForANewSeries(t *testing.T) {
store := newTestStore(t)
var created []Series
store.OnSeriesCreated = func(sr Series) { created = append(created, sr) }
b := Bookmark{
Key: "comix:solo", Site: "comix", SeriesID: "solo", Title: "Solo Leveling",
SeriesURL: "https://comix.to/series/solo", Kind: KindManga, UpdatedAt: 1000,
}
if _, err := store.Upsert(store.OwnerID(), b); err != nil {
t.Fatalf("Upsert: %v", err)
}
b.LastChapterNum = 12
b.UpdatedAt = 2000
if _, err := store.Upsert(store.OwnerID(), b); err != nil {
t.Fatalf("second Upsert: %v", err)
}
if _, err := store.Upsert(secondReader(t, store), b); err != nil {
t.Fatalf("second reader Upsert: %v", err)
}
if len(created) != 1 {
t.Fatalf("hook fired %d times, want 1: %+v", len(created), created)
}
if created[0].Site != "comix" || created[0].SeriesID != "solo" ||
created[0].SeriesURL != "https://comix.to/series/solo" {
t.Fatalf("hook got %+v, want the created series' identity and URL", created[0])
}
}
// Acquisition at creation and the poll both write covers, and whichever
// arrives second must leave the first one alone: a Cover is replaced by
// nothing short of the series row being rebuilt.
func TestSetSeriesCoverDoesNotOverwrite(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
first := "https://asurascans.com/covers/first.jpg"
if err := store.SetSeriesCover("asura", "solo", first, []byte("first"), "image/jpeg"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
if err := store.SetSeriesCover("asura", "solo", "https://asurascans.com/covers/second.jpg",
[]byte("second"), "image/jpeg"); err != nil {
t.Fatalf("second SetSeriesCover: %v", err)
}
got, ok, err := store.Get(store.OwnerID(), "asura:solo")
if err != nil || !ok {
t.Fatalf("Get = %v, %v", ok, err)
}
if want := "https://bookmarks.test/covers/" + CoverAddress(first); got.Cover != want {
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
}
}
// The address comes straight off a public request path, so anything that is
// not a stored address must be a miss rather than a filesystem lookup.
func TestCoverByAddress(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
source := "https://asurascans.com/covers/solo.jpg"
if err := store.SetSeriesCover("asura", "solo", source, []byte("bytes"), "image/jpeg"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
body, contentType, ok, err := store.CoverByAddress(CoverAddress(source))
if err != nil || !ok {
t.Fatalf("CoverByAddress = %v, %v", ok, err)
}
if string(body) != "bytes" || contentType != "image/jpeg" {
t.Fatalf("CoverByAddress = %q, %q, want the stored bytes", body, contentType)
}
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddress(source)[2:],
CoverAddress("never stored")} {
_, _, ok, err := store.CoverByAddress(address)
if err != nil || ok {
t.Fatalf("CoverByAddress(%q) = %v, %v, want a clean miss", address, ok, err)
}
}
}
// A PUT naming an existing series must not overwrite its title or URL: the row
// is shared, and those values are scraped page content (ADR-0003). An acquired
// Cover is likewise untouched by any client.
func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
LastChapterNum: 10, UpdatedAt: 1000,
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
acquired := "https://asurascans.com/covers/solo.jpg"
if err := store.SetSeriesCover("asura", "solo", acquired, []byte("bytes"), "image/jpeg"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
// Same series, hostile/compromised values, real progress advance.
base.Title = "Scraped Rename"
base.SeriesURL = "https://evil.example/solo"
base.Cover = "https://evil.example/solo.jpg"
base.LastChapterNum = 11
got, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
got.Cover != wantCover {
t.Fatalf("stored = %+v, want original title/url/cover kept", got)
}
if got.LastChapterNum != 11 {
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
}
}
// Kind and latest-chapter are last-write-wins even on an existing series: the
// poller and the userscript both report the latest chapter, and kind is only
// known to whichever client created the row.
func TestUpsertExistingSeriesAcceptsKindAndLatest(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
UpdatedAt: 1000,
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
num := 12.0
base.Kind = KindNovel
base.LatestChapter = "Chapter 12"
base.LatestChapterNum = &num
got, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindNovel || got.LatestChapter != "Chapter 12" ||
got.LatestChapterNum == nil || *got.LatestChapterNum != 12 {
t.Fatalf("stored = %+v, want kind and latest chapter updated", got)
}
}
// Deleting the last bookmark must leave the series row behind, so a later
// re-bookmark shows title and cover immediately instead of re-acquiring them.
func TestDeleteKeepsSeriesRow(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
acquired := "https://asurascans.com/covers/solo.jpg"
if err := store.SetSeriesCover("asura", "solo", acquired, []byte("bytes"), "image/jpeg"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
t.Fatalf("Delete: %v", err)
}
sr := readSeries(t, store, "asura", "solo")
if sr.Title != "Solo Leveling" {
t.Fatalf("series = %+v, want it kept after the last bookmark is deleted", sr)
}
// Re-bookmark with nothing but progress: the stored title/cover come back.
stored, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
LastChapterNum: 5, UpdatedAt: 2000,
})
if err != nil {
t.Fatalf("re-upsert: %v", err)
}
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
if stored.Title != "Solo Leveling" || stored.Cover != wantCover {
t.Fatalf("re-bookmark = %+v, want title/cover from the surviving series row", stored)
}
}
// seedSecondReader inserts an extra bookmark on an existing series, owned by a
// second reader. Two bookmarks can share a series only across readers now
// (issue #22); the due queue's reader-count ordering counts them all.
func seedSecondReader(t *testing.T, s *Store, key, site, seriesID string, updatedAt int64) {
t.Helper()
if _, err := s.Upsert(secondReader(t, s), Bookmark{
Key: key, Site: site, SeriesID: seriesID, UpdatedAt: updatedAt,
}); err != nil {
t.Fatalf("seed second reader %q: %v", key, err)
}
}
// The whole point of the split: a shared series is due once, ordered ahead of
// single-reader series by how many bookmarks reference it.
func TestDueForLatestCheckOrdersByReaderCountThenAge(t *testing.T) {
s := newTestStore(t)
// "pop" has two bookmarks but was checked most recently; "solo" has one and
// was checked long ago. Reader count must win over age.
seedForCheck(t, s, "asura:pop", "https://asurascans.com/comics/pop", 900)
seedSecondReader(t, s, "asura:pop:2", "asura", "pop", 1001)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 100)
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 2 {
t.Fatalf("due = %d rows, want 2", len(due))
}
if due[0].Key() != "asura:pop" || due[1].Key() != "asura:solo" {
t.Fatalf("due order = %q, %q; want asura:pop (2 readers), asura:solo (1)",
due[0].Key(), due[1].Key())
}
}
// A series with no bookmarks must never appear in the due queue, and nothing
// in the store ever deletes it (see TestDeleteKeepsSeriesRow).
func TestDueForLatestCheckExcludesOrphanSeries(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:kept", "https://asurascans.com/comics/kept", 0)
if _, err := s.db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover, kind,
latest_chapter, latest_chapter_num, latest_checked_at)
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
'', 'manga', '', NULL, 0)`); err != nil {
t.Fatalf("seed orphan series: %v", err)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:kept" {
t.Fatalf("due = %+v, want only the bookmarked series", due)
}
var n int
if err := s.db.QueryRow(
`SELECT count(*) FROM series WHERE site = 'asura' AND series_id = 'orphan'`).Scan(&n); err != nil {
t.Fatalf("count orphan series: %v", err)
}
if n != 1 {
t.Fatalf("orphan series count = %d, want 1 (never deleted)", n)
}
}
// The seed must never multiply the owner row: reopening the same database with
// a different token hash refreshes the stored hash, not the row. That is what
// keeps the readers table at exactly one row across restarts and token
// rotations.
func TestSeedOwnerIdempotentAndRefreshesTokenHash(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
first, err := Open(url, Owner{DiscordID: "owner", TokenHash: sha256.Sum256([]byte("hash-v1"))}, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
ownerID := first.OwnerID()
first.Close()
second, err := Open(url, Owner{DiscordID: "owner", TokenHash: sha256.Sum256([]byte("hash-v2"))}, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer second.Close()
if second.OwnerID() != ownerID {
t.Fatalf("owner id = %d after reopen, want %d (same row)", second.OwnerID(), ownerID)
}
var (
n int
hash []byte
)
if err := second.db.QueryRow(`SELECT count(*), (SELECT token_sha256 FROM readers LIMIT 1) FROM readers`).Scan(&n, &hash); err != nil {
t.Fatalf("read readers: %v", err)
}
if n != 1 {
t.Fatalf("readers count = %d, want 1", n)
}
want := sha256.Sum256([]byte("hash-v2"))
if !bytes.Equal(hash, want[:]) {
t.Fatalf("token hash = %x, want the refreshed sha256", hash)
}
}
// The upgrade path for a deployed database: bookmarks created before readers
// existed must all land on the seeded owner, the key column must be gone, and
// the same database must be able to hold two readers' bookmarks for one series.
func TestMigration0004AttachesBookmarksToOwner(t *testing.T) {
url := pgtest.URL(t)
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { db.Close() })
// A database at the state before #21 shipped: 0001 applied, bookmarks
// keyed by <site>:<series_id>, no series table. Rows land before 0002, the
// way a real deployment's data did.
if err := migrate(db, 1); err != nil {
t.Fatalf("migrate to 0001: %v", err)
}
for _, key := range []string{"asura:solo", "demonic:catastrophic-necromancer"} {
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
if _, err := db.Exec(`
INSERT INTO bookmarks (key, site, series_id, updated_at)
VALUES ($1, $2, $3, 1000)`, key, site, seriesID); err != nil {
t.Fatalf("seed legacy row %q: %v", key, err)
}
}
// 0002 backfills the series rows, as it did in the real upgrade.
if err := migrate(db, 2); err != nil {
t.Fatalf("migrate to 0002: %v", err)
}
st, err := Open(url, testOwner, t.TempDir(), testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
var (
attached int
readers int
)
if err := st.db.QueryRow(
`SELECT count(*) FROM bookmarks WHERE reader_id = $1`, st.OwnerID()).Scan(&attached); err != nil {
t.Fatalf("count attached bookmarks: %v", err)
}
if attached != 2 {
t.Fatalf("bookmarks attached to owner = %d, want all 2", attached)
}
if err := st.db.QueryRow(`SELECT count(*) FROM readers`).Scan(&readers); err != nil {
t.Fatalf("count readers: %v", err)
}
if readers != 1 {
t.Fatalf("readers = %d, want 1", readers)
}
// The surrogate key column is gone; only the composite key remains.
if _, err := st.db.Query(`SELECT key FROM bookmarks`); err == nil {
t.Fatal("bookmarks.key still exists after the migration")
}
}
// One Reader and Series pair must admit at most one bookmark, enforced by the
// primary key itself — a raw INSERT that skips the upsert must fail.
func TestBookmarkDuplicateImpossibleAtDatabaseLevel(t *testing.T) {
st := newTestStore(t)
// A series row on its own, no bookmark: the raw inserts below must only
// ever collide on the bookmark primary key.
if _, err := st.db.Exec(
`INSERT INTO series (site, series_id) VALUES ('asura', 'solo')`); err != nil {
t.Fatalf("seed series: %v", err)
}
insert := func() error {
_, err := st.db.Exec(`
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
VALUES ($1, 'asura', 'solo', 1000)`, st.OwnerID())
return err
}
if err := insert(); err != nil {
t.Fatalf("first insert: %v", err)
}
if err := insert(); err == nil {
t.Fatal("duplicate bookmark for the same reader and series was accepted")
}
}
// Every read and write is scoped to the reader it names: a second reader sees
// an empty list, cannot read or delete the owner's row, and a delete by the
// wrong reader leaves the row alone.
func TestStoreScopesBookmarksToReader(t *testing.T) {
st := newTestStore(t)
other := secondReader(t, st)
if _, err := st.Upsert(st.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed owner bookmark: %v", err)
}
otherList, err := st.List(other)
if err != nil {
t.Fatalf("List(other): %v", err)
}
if len(otherList) != 0 {
t.Fatalf("other reader's list = %+v, want empty", otherList)
}
if _, ok, err := st.Get(other, "asura:solo"); err != nil || ok {
t.Fatalf("Get(other, asura:solo) = ok:%v err:%v, want not found", ok, err)
}
if err := st.Delete(other, "asura:solo"); err != nil {
t.Fatalf("Delete(other): %v", err)
}
ownerList, err := st.List(st.OwnerID())
if err != nil {
t.Fatalf("List(owner): %v", err)
}
if len(ownerList) != 1 || ownerList[0].Key != "asura:solo" {
t.Fatalf("owner's list after other's delete = %+v, want the row intact", ownerList)
}
// The same key under a second reader is an independent bookmark.
if _, err := st.Upsert(other, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
}); err != nil {
t.Fatalf("upsert other's bookmark: %v", err)
}
if got, err := st.List(other); err != nil || len(got) != 1 {
t.Fatalf("other's list after own upsert = %+v err:%v, want 1 row", got, err)
}
}
// Deleting a reader must take their bookmarks with them (ON DELETE CASCADE)
// while leaving the shared series row behind.
func TestDeleteReaderCascadesToBookmarks(t *testing.T) {
st := newTestStore(t)
other := secondReader(t, st)
if _, err := st.Upsert(other, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed other's bookmark: %v", err)
}
if _, err := st.db.Exec(`DELETE FROM readers WHERE id = $1`, other); err != nil {
t.Fatalf("delete reader: %v", err)
}
var n int
if err := st.db.QueryRow(`SELECT count(*) FROM bookmarks`).Scan(&n); err != nil {
t.Fatalf("count bookmarks: %v", err)
}
if n != 0 {
t.Fatalf("bookmarks after reader delete = %d, want 0 (cascade)", n)
}
sr := readSeries(t, st, "asura", "solo")
if sr.Title != "Solo Leveling" {
t.Fatalf("series = %+v, want it kept after its only reader was deleted", sr)
}
}
// Registration is one code path: the first sight of a Discord identity creates
// the Reader, every later one returns the same row. The epoch-0 hash argument
// is for creation only — a returning Reader who has rotated must not have that
// rotation undone by logging in again.
func TestEnsureReaderCreatesOnceAndNeverClobbersARotation(t *testing.T) {
s := newTestStore(t)
first, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
if first == s.OwnerID() {
t.Fatal("a new Discord identity resolved to the owner Reader")
}
if id, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil || !ok || id != first {
t.Fatalf("new Reader's credential resolved to (%d, %v, %v), want (%d, true, nil)", id, ok, err, first)
}
rotated := sha256.Sum256([]byte("cred-epoch-1"))
if err := s.RotateToken(first, 0, rotated); err != nil {
t.Fatalf("RotateToken: %v", err)
}
again, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
if err != nil {
t.Fatalf("second EnsureReader: %v", err)
}
if again != first {
t.Fatalf("second login returned Reader %d, want the existing %d", again, first)
}
if _, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil {
t.Fatalf("stale lookup: %v", err)
} else if ok {
t.Fatal("logging in again revived the pre-rotation credential")
}
if id, ok, err := s.ReaderIDForTokenHash(rotated); err != nil || !ok || id != first {
t.Fatalf("rotated credential resolved to (%d, %v, %v), want the same Reader", id, ok, err)
}
// Signing in as the owner's own Discord identity reuses the seeded row
// rather than minting a duplicate library.
if id, err := s.EnsureReader(testOwner.DiscordID, sha256.Sum256([]byte("ignored"))); err != nil {
t.Fatalf("EnsureReader(owner): %v", err)
} else if id != s.OwnerID() {
t.Fatalf("owner login returned Reader %d, want the seeded owner %d", id, s.OwnerID())
}
}
// The owner's administration view: who exists and how many live sessions each
// holds. Revocation drops all of one Reader's sessions and nobody else's.
func TestReadersAndSessionRevocation(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
for _, id := range []string{"own-1", "own-2"} {
if _, err := s.CreateSession(id, s.OwnerID(), time.Hour); err != nil {
t.Fatalf("CreateSession(%s): %v", id, err)
}
}
if _, err := s.CreateSession("other-1", other, time.Hour); err != nil {
t.Fatalf("CreateSession(other): %v", err)
}
// An expired row must not be counted as a session the owner can revoke.
if _, err := s.CreateSession("other-dead", other, -time.Minute); err != nil {
t.Fatalf("CreateSession(expired): %v", err)
}
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
if len(readers) != 2 || readers[0].ID != s.OwnerID() || readers[1].ID != other {
t.Fatalf("readers = %+v, want the owner then the second Reader", readers)
}
if readers[0].DiscordID != testOwner.DiscordID {
t.Fatalf("owner discord id = %q, want %q", readers[0].DiscordID, testOwner.DiscordID)
}
if readers[0].Sessions != 2 || readers[1].Sessions != 1 {
t.Fatalf("session counts = %d, %d; want 2 and 1 live", readers[0].Sessions, readers[1].Sessions)
}
if err := s.DeleteReaderSessions(other); err != nil {
t.Fatalf("DeleteReaderSessions: %v", err)
}
if _, ok, err := s.GetSession("other-1", time.Now()); err != nil || ok {
t.Fatalf("revoked session still resolves: ok=%v err=%v", ok, err)
}
if _, ok, err := s.GetSession("own-1", time.Now()); err != nil || !ok {
t.Fatalf("owner's session was collateral: ok=%v err=%v", ok, err)
}
}
// The Sighting counters ship before the mechanism that fills them (issue
// #102 before #103), so the admin page depends on their default: a fresh
// Reader reads back trusted. Marking one directly proves Readers() reports
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
// the owner's remedy for a false mark — zeroes them again.
func TestReaderSightingMarks(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
if len(readers) != 2 {
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
}
for _, r := range readers {
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
}
}
// The counters' default is the trusted state; writing them directly is
// the only way to exercise the read path until issue #103 moves them.
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
WHERE id = $1`, other); err != nil {
t.Fatalf("mark reader: %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
var marked *ReaderSummary
for i := range readers {
if readers[i].ID == other {
marked = &readers[i]
}
}
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
}
if marked.Blocked() {
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
}
if _, err := s.db.Exec(`
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
t.Fatalf("block reader: %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.ID == other && !r.Blocked() {
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
}
if r.ID == s.OwnerID() && r.Blocked() {
t.Fatalf("untouched owner became blocked: %+v", r)
}
}
if err := s.ClearReaderMarks(other); err != nil {
t.Fatalf("ClearReaderMarks: %v", err)
}
if err := s.ClearReaderMarks(other + 9999); err != nil {
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
}
}
}
// Two Readers on one Series: one series row, two independent progresses. The
// second Reader starts at zero however far the first has read, and the shared
// row is still due exactly once.
func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
if _, err := s.Upsert(s.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
LastChapter: "Chapter 200", LastChapterNum: 200, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed owner: %v", err)
}
theirs, err := s.Upsert(other, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
})
if err != nil {
t.Fatalf("seed other: %v", err)
}
if theirs.LastChapterNum != 0 || theirs.LastChapter != "" {
t.Fatalf("second Reader's progress = %+v, want zero regardless of the first's 200", theirs)
}
// The shared facts are still shared: the series row it joined to is the
// one the first Reader created.
if theirs.Title != "Solo Leveling" {
t.Fatalf("second Reader's title = %q, want the shared series title", theirs.Title)
}
var series int
if err := s.db.QueryRow(`SELECT count(*) FROM series`).Scan(&series); err != nil {
t.Fatalf("count series: %v", err)
}
if series != 1 {
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
}
due, err := s.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:solo" {
t.Fatalf("due = %+v, want the shared series exactly once per cycle", due)
}
// One Reader dropping their bookmark leaves the other's intact and the
// series still polled.
if err := s.Delete(other, "asura:solo"); err != nil {
t.Fatalf("Delete(other): %v", err)
}
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
}
due, err = s.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck after delete: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:solo" {
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
}
}
func TestCoverPersistsAcrossReopen(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
body := []byte("stored-cover")
const sourceURL = "https://cdn.example/covers/series.jpg"
if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
t.Fatalf("PutCover: %v", err)
}
if err := first.Close(); err != nil {
t.Fatalf("close first store: %v", err)
}
second, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer second.Close()
got, contentType, ok, err := second.GetCover(sourceURL)
if err != nil {
t.Fatalf("GetCover: %v", err)
}
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" {
t.Fatalf("stored cover = (%q, %q, %v), want (%q, image/webp, true)", got, contentType, ok, body)
}
}
func TestOpenRequiresCoverDirectory(t *testing.T) {
if _, err := Open(pgtest.URL(t), testOwner, "", testCoverBaseURL); err == nil || !strings.Contains(err.Error(), "cover directory is required") {
t.Fatalf("Open without cover directory = %v, want required-directory error", err)
}
}
// A base URL without a scheme reads like a hostname and starts cleanly, but
// every Cover it puts on the wire is an address no browser can resolve.
func TestOpenRequiresAbsoluteCoverBaseURL(t *testing.T) {
for _, base := range []string{"", "bookmarks.test", "https://", "ftp://bookmarks.test"} {
if _, err := Open(pgtest.URL(t), testOwner, t.TempDir(), base); err == nil ||
!strings.Contains(err.Error(), "absolute http(s) origin") {
t.Fatalf("Open with base %q = %v, want absolute-origin error", base, err)
}
}
}
func TestCoverIsContentAddressedOnFilesystem(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
body := []byte("stored-cover")
const sourceURL = "https://cdn.example/covers/series.jpg"
if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
first.Close()
t.Fatalf("PutCover: %v", err)
}
defer first.Close()
addressBytes := sha256.Sum256([]byte(sourceURL))
address := hex.EncodeToString(addressBytes[:])
wantPath := filepath.Join(address[:2], address[2:4], address)
var gotPath, contentType string
if err := first.db.QueryRow(`SELECT path, content_type FROM covers WHERE address = $1`, address).Scan(&gotPath, &contentType); err != nil {
t.Fatalf("cover row: %v", err)
}
if gotPath != wantPath || contentType != "image/webp" {
t.Fatalf("cover row = (%q, %q), want (%q, image/webp)", gotPath, contentType, wantPath)
}
if got, err := os.ReadFile(filepath.Join(coverDir, gotPath)); err != nil || !bytes.Equal(got, body) {
t.Fatalf("cover file = (%q, %v), want (%q, nil)", got, err, body)
}
var bodyColumn int
if err := first.db.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_name = 'covers' AND column_name = 'body'`).Scan(&bodyColumn); err != nil {
t.Fatalf("cover columns: %v", err)
}
if bodyColumn != 0 {
t.Fatalf("covers still has body column")
}
}
func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
s := newTestStore(t)
const sourceURL = "https://cdn.example/covers/series.jpg"
want := []byte("cover-bytes")
if err := s.PutCover(sourceURL, want, "image/jpeg"); err != nil {
t.Fatalf("PutCover: %v", err)
}
got, contentType, ok, err := s.GetCover(sourceURL)
if err != nil {
t.Fatalf("GetCover: %v", err)
}
if !ok || !bytes.Equal(got, want) || contentType != "image/jpeg" {
t.Fatalf("GetCover = (%q, %q, %v), want (%q, image/jpeg, true)", got, contentType, ok, want)
}
if err := s.PutCover("https://cdn.example/not-image", []byte("html"), "text/html"); err == nil {
t.Fatal("PutCover accepted a non-image")
}
if _, _, ok, err := s.GetCover("https://cdn.example/not-image"); err != nil || ok {
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
}
}