Files
mangaBookmark/backend/internal/store/store_test.go
T
sulthan bc64a1d894 backend: remove orphan series from the admin (ticket #155)
(*Store).RemoveSeries deletes one series row by (site, series_id) via a
plain parameterized DELETE; a bookmarks_series_fk violation outside
23503 is translated into the ErrSeriesHasBookmarks sentinel so no
driver type escapes the store. The caller reads the row's cover
address before the delete and reclaims it after: ReclaimCover's guard
cannot pass while a series row still points at the address.

POST /admin/series/{key}/remove answers the list row with the removed
row's fragment plus the heading re-rendered out of band at the fresh
count (HX-Reswap: delete removes the row through the same button that
swaps the refusal back in), and navigates from the detail page to the
No-Readers list (HX-Redirect for htmx, a 303 for plain clients). A
removal that races a fresh Bookmark is a refusal, not a 500: the row
re-renders at its new count with the fact spelled out. The control
renders only at zero Reader count on both surfaces, gated by hx-confirm
with the brief's copy.
2026-08-22 09:55:01 +07:00

2452 lines
89 KiB
Go

package store
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io/fs"
"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+`, false AS forced, 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/" + CoverAddressForBytes([]byte("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(CoverAddressForBytes([]byte("bytes")))
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" + CoverAddressForBytes([]byte("bytes"))[2:],
CoverAddressForBytes([]byte("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/" + CoverAddressForBytes([]byte("bytes"))
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/" + CoverAddressForBytes([]byte("bytes"))
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.CoverByAddress(CoverAddressForBytes(body))
if err != nil {
t.Fatalf("CoverByAddress: %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(body)
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.CoverByAddress(CoverAddressForBytes(want))
if err != nil {
t.Fatalf("CoverByAddress: %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.CoverByAddress(CoverAddressForBytes([]byte("html"))); err != nil || ok {
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
}
}
func TestRecordLanePassPrunesBeforeInsertCutoff(t *testing.T) {
s := newTestStore(t)
for _, pass := range []LanePass{
{Site: "asura", RanAt: 99},
{Site: "asura", RanAt: 100},
} {
if err := s.RecordLanePass(pass, 100); err != nil {
t.Fatalf("RecordLanePass(%d): %v", pass.RanAt, err)
}
}
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 200}, 100); err != nil {
t.Fatalf("RecordLanePass(200): %v", err)
}
var count int
if err := s.db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, "asura").Scan(&count); err != nil {
t.Fatalf("count passes: %v", err)
}
if count != 2 {
t.Fatalf("retained passes = %d, want 2", count)
}
if _, ok, err := s.LatestLanePass("asura"); err != nil || !ok {
t.Fatalf("LatestLanePass = ok %v, err %v; want latest row", ok, err)
}
}
func TestLatestLanePassesKeepsNewestPerSiteAndJoinsState(t *testing.T) {
s := newTestStore(t)
for _, pass := range []LanePass{
{Site: "asura", RanAt: 100, Due: 1},
{Site: "asura", RanAt: 200, Skip: "due-query", Due: 2, Checked: 3, GapMS: 4000, Clamped: true},
{Site: "demonic", RanAt: 150, Due: 4},
} {
if err := s.RecordLanePass(pass, -1); err != nil {
t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err)
}
}
if err := s.PauseLane("asura", 1234); err != nil {
t.Fatalf("PauseLane: %v", err)
}
if err := s.SetLaneRefusal("asura", 5678); err != nil {
t.Fatalf("SetLaneRefusal: %v", err)
}
got, err := s.LatestLanePasses()
if err != nil {
t.Fatalf("LatestLanePasses: %v", err)
}
if len(got) != 2 {
t.Fatalf("latest passes = %d, want one per Site", len(got))
}
bySite := map[string]LanePass{}
for _, pass := range got {
bySite[pass.Site] = pass
}
asura := bySite["asura"]
if asura.RanAt != 200 || asura.Skip != "due-query" || asura.Due != 2 || asura.Checked != 3 || asura.GapMS != 4000 || !asura.Clamped ||
asura.PausedUntil != 1234 || asura.RefuseUntil != 5678 {
t.Fatalf("asura latest pass = %+v, want newest pass and joined state", asura)
}
if demonic := bySite["demonic"]; demonic.RanAt != 150 || demonic.Due != 4 {
t.Fatalf("demonic latest pass = %+v, want its only pass", demonic)
}
}
func TestLanePassOutcomesSumsWindow(t *testing.T) {
s := newTestStore(t)
for _, pass := range []LanePass{
{Site: "asura", RanAt: 99, Refused: 1, Unreachable: 2, NoChapter: 3, Unfetchable: 4, Errors: 5},
{Site: "asura", RanAt: 100, Refused: 2, Unreachable: 3, NoChapter: 4, Unfetchable: 5, Errors: 6},
{Site: "asura", RanAt: 200, Refused: 3, Unreachable: 4, NoChapter: 5, Unfetchable: 6, Errors: 7},
{Site: "demonic", RanAt: 150, Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12},
} {
if err := s.RecordLanePass(pass, -1); err != nil {
t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err)
}
}
got, err := s.LanePassOutcomes(100)
if err != nil {
t.Fatalf("LanePassOutcomes: %v", err)
}
if len(got) != 2 {
t.Fatalf("outcome Sites = %d, want 2", len(got))
}
bySite := map[string]SiteOutcomes{}
for _, outcomes := range got {
bySite[outcomes.Site] = outcomes
}
if want := (SiteOutcomes{Site: "asura", Refused: 5, Unreachable: 7, NoChapter: 9, Unfetchable: 11, Errors: 13}); bySite["asura"] != want {
t.Fatalf("asura outcomes = %+v, want %+v", bySite["asura"], want)
}
if want := (SiteOutcomes{Site: "demonic", Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12}); bySite["demonic"] != want {
t.Fatalf("demonic outcomes = %+v, want %+v", bySite["demonic"], want)
}
}
func TestLaneGatesReadsOneRow(t *testing.T) {
s := newTestStore(t)
paused, refused, err := s.LaneGates("asura")
if err != nil || paused != 0 || refused != 0 {
t.Fatalf("LaneGates on a missing state row = (%d, %d, %v), want (0, 0, nil)", paused, refused, err)
}
if err := s.PauseLane("asura", 2000); err != nil {
t.Fatalf("PauseLane: %v", err)
}
if err := s.SetLaneRefusal("asura", 3000); err != nil {
t.Fatalf("SetLaneRefusal: %v", err)
}
paused, refused, err = s.LaneGates("asura")
if err != nil || paused != 2000 || refused != 3000 {
t.Fatalf("LaneGates = (%d, %d, %v), want (2000, 3000, nil)", paused, refused, err)
}
}
func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
s := newTestStore(t)
for _, until := range []int64{0, -1} {
if err := s.PauseLane("asura", until); err == nil {
t.Fatalf("PauseLane(%d) accepted a non-future expiry", until)
}
}
if err := s.PauseLane("asura", 2000); err != nil {
t.Fatalf("PauseLane: %v", err)
}
if err := s.SetLaneRefusal("asura", 3000); err != nil {
t.Fatalf("SetLaneRefusal: %v", err)
}
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 1}, -1); err != nil {
t.Fatalf("RecordLanePass: %v", err)
}
pausedUntil, refuseUntil, err := s.LaneGates("asura")
if err != nil || pausedUntil != 2000 || refuseUntil != 3000 {
t.Fatalf("LaneGates = %d/%d, %v; want 2000/3000", pausedUntil, refuseUntil, err)
}
paused, err := s.PausedLanes()
if err != nil {
t.Fatalf("PausedLanes: %v", err)
}
if len(paused) != 1 || paused[0] != (LanePause{Site: "asura", PausedUntil: 2000}) {
t.Fatalf("PausedLanes = %+v, want asura/2000", paused)
}
if err := s.ResumeLane("asura"); err != nil {
t.Fatalf("ResumeLane: %v", err)
}
latest, ok, err := s.LatestLanePass("asura")
if err != nil || !ok || latest.RefuseUntil != 3000 {
t.Fatalf("latest refusal after resume = %+v, ok=%v, err=%v; want 3000 preserved", latest, ok, err)
}
if got, got2, err := s.LaneGates("asura"); err != nil || got != 0 || got2 != 3000 {
t.Fatalf("LaneGates after resume = %d/%d, %v; want 0/3000", got, got2, err)
}
if paused, err := s.PausedLanes(); err != nil || len(paused) != 0 {
t.Fatalf("PausedLanes after resume = %+v, %v; want empty", paused, err)
}
var rows int
if err := s.db.QueryRow(`SELECT count(*) FROM poll_lanes WHERE site = $1`, "asura").Scan(&rows); err != nil {
t.Fatalf("count lane state: %v", err)
}
if rows != 1 {
t.Fatalf("lane state rows after resume = %d, want 1", rows)
}
}
// A forced Series is due ahead of the rest cutoff: the request overrides the
// rest gate so the Lane's next pass picks it up however recently it was
// checked. An unforced series under the rest stays out.
func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) {
s := newTestStore(t)
const now = int64(10 * 3600_000)
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000)
seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000)
if err := s.ForceSeriesPoll("asura", "forced", now); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:forced" {
t.Fatalf("due = %v, want only the forced series", due)
}
}
// The Sighting-deferral clause holds a solitary series back after a recent
// Reader report; a forced request overrides it and puts the series back on
// the Lane's list.
func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
s := newTestStore(t)
const now = int64(10 * 3600_000)
// One bookmark (so deferral can apply), sighted and checked 10m ago:
// inside the deferral window and under the ceiling.
seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000)
if _, err := s.db.Exec(
`UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`,
now-10*60_000); err != nil {
t.Fatalf("seed sighting: %v", err)
}
// Unforced: deferred, and under the rest anyway.
due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("unforced deferred series is due: %v", due)
}
// Forced: the request overrides the deferral.
if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:deferred" {
t.Fatalf("forced deferred series not due: %v", due)
}
}
// The finished-only bucket excludes a series whose only bookmarks are
// finished; a forced request overrides it — the owner asked, so the Lane
// looks.
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
if _, err := s.Upsert(s.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)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
for _, sr := range due {
if sr.Key() == "asura:finished" {
t.Fatalf("unforced finished series is due: %v", due)
}
}
if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
due, err = s.DueForLatestCheck("asura", 1000, 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:finished"] {
t.Fatalf("forced finished series not due: %v", due)
}
}
// A forced Series jumps the queue: it sorts ahead of a more-read series that
// is due by rest, without disturbing the reader-count-then-age tie-break
// among the unforced rows (ADR-0003).
func TestDueForLatestCheckForcedSortsFirst(t *testing.T) {
s := newTestStore(t)
// "popular" has two readers and is long overdue; "forced" has one reader
// and a fresh check stamp. The forced row must come first.
seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100)
seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001)
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900)
if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
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:forced" || due[1].Key() != "asura:popular" {
t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key())
}
}
// A forced Series with no series URL is still not fetched — nothing to fetch —
// and one with no Bookmarks is still excluded by the join. The force flag
// opens the three gates it is allowed to, not the whole query.
func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:nourl", "", 0)
if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
if _, err := s.db.Exec(`
INSERT INTO series (site, series_id, title, series_url, cover, kind,
latest_chapter, latest_chapter_num, latest_checked_at,
force_poll_at)
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
'', 'manga', '', NULL, 0, 5000)`); err != nil {
t.Fatalf("seed orphan: %v", err)
}
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
}
}
// A Correction writes the number, the derived label and the stamp, clears the
// raising Reader, and never touches either Sighting counter or the check
// stamp — a Correction is not a check and never judges a Reader (#149).
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
// A Reader raised the number, and carries a mark for it.
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
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)
}
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
var chapter string
var num float64
var stamp, checkedAt int64
var raisedBy any
if err := s.db.QueryRow(`
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
latest_checked_at, latest_raised_by
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
t.Fatalf("read back: %v", err)
}
if chapter != "Chapter 12.5" {
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
}
if num != 12.5 {
t.Errorf("latest_chapter_num = %v, want 12.5", num)
}
if stamp != 9000 {
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
}
if checkedAt != 4321_000 {
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
}
if raisedBy != nil {
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
}
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
}
}
}
// The stamp follows the number (spec #135): an Upsert resending the corrected
// value — a Reader's cached row after a correction — keeps it, and an Upsert
// that actually moves the number kills it. Unconditional zeroing would erase
// the fact while the value is still the owner's; that is the whole point of
// the clause.
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
s := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
}
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
// Same number back: the value is still the owner's.
same := base
same.LatestChapterNum = num2(5)
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
t.Fatalf("same-number upsert: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
}
// A different number: a machine (or a Reader) wrote the value.
moved := base
moved.LatestChapterNum = num2(7)
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
t.Fatalf("moved upsert: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
}
}
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
// calls it when the number differs, so the condition lives upstream and a
// second copy here would drift (#149).
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
t.Fatalf("SetLatestChapter: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
}
}
// latestCorrectedAt reads the stamp column for the assertion above.
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
t.Helper()
var stamp int64
if err := s.db.QueryRow(
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
site, seriesID).Scan(&stamp); err != nil {
t.Fatalf("read stamp: %v", err)
}
return stamp
}
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
func num2(f float64) *float64 { return &f }
// --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 ---
// The address is what makes a re-art visible at all, so the same bytes must
// always name the same address and different bytes different ones — and the
// address must keep the 64-hex-digit shape CoverByAddress's guard still checks
// before any request-supplied value becomes a filesystem path.
func TestCoverAddressForBytesIsDeterministicAndDistinct(t *testing.T) {
first := CoverAddressForBytes([]byte("art"))
again := CoverAddressForBytes([]byte("art"))
other := CoverAddressForBytes([]byte("artwork"))
if first != again {
t.Fatalf("same bytes gave %q then %q, want one address", first, again)
}
if first == other {
t.Fatalf("different bytes gave the same address %q", first)
}
if !coverAddressRe.MatchString(first) {
t.Fatalf("address %q is not the 64-hex-digit shape the serving guard checks", first)
}
}
// ReplaceSeriesCover is the forced-replacement installer: it moves a Cover
// whether or not one exists, writes the source URL alongside it, and reports
// the three outcomes the Forced Poll has to tell apart.
func TestReplaceSeriesCover(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)
}
// A blank Cover: previous is "", and the row points at the new bytes.
previous, current, err := store.ReplaceSeriesCover("asura", "solo",
"https://cdn.asurascans.com/covers/solo.webp", []byte("first-art"), "image/webp")
if err != nil {
t.Fatalf("ReplaceSeriesCover on a blank: %v", err)
}
if previous != "" {
t.Fatalf("previous on a blank = %q, want empty", previous)
}
if want := CoverAddressForBytes([]byte("first-art")); current != want {
t.Fatalf("current = %q, want %q", current, want)
}
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
sr.Cover != "https://cdn.asurascans.com/covers/solo.webp" {
t.Fatalf("series after blank fill = %+v, want the new address and source URL", sr)
}
// A re-art: previous is the stranded address, current the new one.
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
if err != nil {
t.Fatalf("ReplaceSeriesCover over a filled Cover: %v", err)
}
if want := CoverAddressForBytes([]byte("first-art")); previous != want {
t.Fatalf("previous = %q, want the replaced address %q", previous, want)
}
if want := CoverAddressForBytes([]byte("second-art")); current != want {
t.Fatalf("current = %q, want %q", current, want)
}
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" {
t.Fatalf("series after replacement = %+v, want the new address and source URL", sr)
}
// The replaced bytes stay served under their old address: ReplaceSeriesCover
// itself reclaims nothing, reclamation is the caller's separate act (#154).
if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok {
t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err)
}
// The Site is serving the same artwork again: previous == current is the
// honest no-op the caller reports as "unchanged".
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
if err != nil {
t.Fatalf("ReplaceSeriesCover over identical bytes: %v", err)
}
if previous != current {
t.Fatalf("identical bytes: previous = %q, current = %q, want one address", previous, current)
}
if want := CoverAddressForBytes([]byte("second-art")); current != want {
t.Fatalf("current = %q, want %q", current, want)
}
}
// Rows written before byte addressing hold the hash of their source URL and
// are never rehashed: GetCover — the poller's heal path — keeps resolving
// them through coverSourceAddress.
func TestGetCoverResolvesLegacyURLDerivedAddress(t *testing.T) {
store := newTestStore(t)
source := "https://cdn.example/legacy.jpg"
legacy := coverSourceAddress(source)
relativePath := coverRelativePath(legacy)
coverPath := filepath.Join(store.coverDir, filepath.FromSlash(relativePath))
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
t.Fatalf("create shard dir: %v", err)
}
if err := os.WriteFile(coverPath, []byte("legacy-bytes"), 0o644); err != nil {
t.Fatalf("write legacy file: %v", err)
}
if _, err := store.db.Exec(
`INSERT INTO covers (address, path, content_type) VALUES ($1, $2, $3)`,
legacy, relativePath, "image/jpeg"); err != nil {
t.Fatalf("plant legacy row: %v", err)
}
body, contentType, ok, err := store.GetCover(source)
if err != nil || !ok {
t.Fatalf("GetCover on a legacy row = %v, %v, want found", ok, err)
}
if string(body) != "legacy-bytes" || contentType != "image/jpeg" {
t.Fatalf("legacy cover = (%q, %q), want the planted bytes", body, contentType)
}
}
// SetSeriesURL is the one write that lifts the write-once rule of
// Series.SeriesURL (issue #151): a client PUT naming an existing Series still
// has its new URL dropped, yet the owner's repair lands where the Upsert
// would have ignored it.
func TestSetSeriesURLWritesWhereUpsertIgnores(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
UpdatedAt: 1000,
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
// A client PUT naming the existing Series is refused: the row is shared,
// so the stored URL stands.
base.SeriesURL = "https://evil.example/solo"
if got, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("Upsert: %v", err)
} else if got.SeriesURL != "https://asurascans.com/comics/solo" {
t.Fatalf("Upsert stored %q, want the original URL untouched", got.SeriesURL)
}
// The owner's repair writes where the Upsert would have ignored it.
repair := "https://asurascans.com/comics/solo-renumbered"
if err := store.SetSeriesURL("asura", "solo", repair); err != nil {
t.Fatalf("SetSeriesURL: %v", err)
}
sr := readSeries(t, store, "asura", "solo")
if sr.SeriesURL != repair {
t.Fatalf("stored URL = %q, want %q", sr.SeriesURL, repair)
}
}
// --- Cover byte reclamation (issue #154): one guarded helper, file first ---
// coverShardPath is the on-disk location of one address's bytes, built the
// same way getCoverByAddress reads them.
func coverShardPath(t *testing.T, s *Store, address string) string {
t.Helper()
return filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
}
// ReclaimCover removes a Cover nothing references: the row alone is not the
// point — the sharded file must be gone too, because the file is the reclaimed
// disk space.
func TestReclaimCoverRemovesUnreferencedBytes(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover: %v", err)
}
old := CoverAddressForBytes([]byte("old-art"))
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
t.Fatalf("replace cover: %v", err)
}
if err := store.ReclaimCover(old); err != nil {
t.Fatalf("ReclaimCover: %v", err)
}
if _, err := os.Stat(coverShardPath(t, store, old)); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("sharded path after reclaim = %v, want fs.ErrNotExist", err)
}
if _, _, ok, err := store.CoverByAddress(old); err != nil || ok {
t.Fatalf("covers row after reclaim = found %v err %v, want gone", ok, err)
}
// The live Cover survives the reclamation of the stranded one.
if body, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("new-art"))); err != nil || !ok || string(body) != "new-art" {
t.Fatalf("new bytes after reclaim = found %v err %v, want still served", ok, err)
}
}
// The guard is the whole design: byte-identical artwork is one covers row by
// construction (ADR-0014), so a second Series pointing at the address must
// keep the bytes — reclaiming one Series' stranded artwork may not blank
// another's.
func TestReclaimCoverSparesReferencedAddress(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
const src = "https://cdn.example/covers/shared.jpg"
addr := CoverAddressForBytes([]byte("shared-art"))
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover: %v", err)
}
// One Series pointing at the address is enough for the guard.
if err := store.ReclaimCover(addr); err != nil {
t.Fatalf("ReclaimCover on a referenced address: %v", err)
}
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
t.Fatalf("bytes after no-op = found %v err %v, want still served", ok, err)
}
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
t.Fatalf("sharded file after no-op: %v, want present", err)
}
// A second Series serving identical bytes shares the row by construction.
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
t.Fatalf("share cover: %v", err)
}
if err := store.ReclaimCover(addr); err != nil {
t.Fatalf("ReclaimCover on a shared address: %v", err)
}
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
t.Fatalf("shared bytes after no-op = found %v err %v, want still served", ok, err)
}
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
t.Fatalf("sharded file after shared no-op: %v, want present", err)
}
}
// A blank address is the wire value for "no Cover" (ADR-0007), never a
// reclaimable one.
func TestReclaimCoverBlankAddressIsNoOp(t *testing.T) {
store := newTestStore(t)
if err := store.ReclaimCover(""); err != nil {
t.Fatalf("ReclaimCover(\"\") = %v, want nil", err)
}
}
// An interrupted reclamation is the state the file-first order exists for:
// the row is the handle, so the unreferenced-covers query finds the torn
// Cover and re-running ReclaimCover finishes the job — a missing file is
// "already gone", which counts as success.
func TestReclaimCoverInterruptedRunIsFindableAndFinishes(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/torn.jpg", []byte("torn-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover: %v", err)
}
torn := CoverAddressForBytes([]byte("torn-art"))
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
t.Fatalf("replace cover: %v", err)
}
if err := os.Remove(coverShardPath(t, store, torn)); err != nil {
t.Fatalf("unlink mid-reclamation: %v", err)
}
var found string
err := store.db.QueryRow(`
SELECT address FROM covers c
WHERE NOT EXISTS (SELECT 1 FROM series s WHERE s.cover_address = c.address)
LIMIT 1`).Scan(&found)
if err != nil || found != torn {
t.Fatalf("unreferenced-covers query = (%q, %v), want the torn row %q", found, err, torn)
}
if err := store.ReclaimCover(torn); err != nil {
t.Fatalf("re-run over a missing file: %v", err)
}
if _, _, ok, err := store.CoverByAddress(torn); err != nil || ok {
t.Fatalf("row after re-run = found %v err %v, want gone", ok, err)
}
}
// A failed file removal is the one state that is not self-cleaning: the
// covers row must survive so a retry can finish the job, and the store
// returns the error rather than logging — each caller logs and carries on,
// so the failure has no user-facing surface.
func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/stuck.jpg", []byte("stuck-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover: %v", err)
}
stuck := CoverAddressForBytes([]byte("stuck-art"))
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/other.jpg", []byte("other-art"), "image/jpeg"); err != nil {
t.Fatalf("replace cover: %v", err)
}
// Make the unlink fail: the sharded path becomes a non-empty directory,
// which os.Remove refuses.
shard := coverShardPath(t, store, stuck)
if err := os.Remove(shard); err != nil {
t.Fatalf("clear file: %v", err)
}
if err := os.Mkdir(shard, 0o755); err != nil {
t.Fatalf("replace file with dir: %v", err)
}
if err := os.WriteFile(filepath.Join(shard, "blob"), []byte("x"), 0o644); err != nil {
t.Fatalf("fill dir: %v", err)
}
if err := store.ReclaimCover(stuck); err == nil {
t.Fatal("ReclaimCover over an unremovable file = nil, want the error")
}
var one int
if err := store.db.QueryRow(`SELECT 1 FROM covers WHERE address = $1`, stuck).Scan(&one); err != nil {
t.Fatal("covers row after failed unlink is gone; want it left for a retry")
}
}
// RemoveSeries is the orphan removal (#155): one Series, one delete, refused
// by the database while any Bookmark points at it. The store translates the
// foreign-key violation into its own sentinel so no driver type escapes, and
// the caller reaps the stranded Cover through ReclaimCover.
func TestRemoveSeriesRemovesOrphanAndReclaimsCover(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
t.Fatalf("orphan the series: %v", err)
}
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover: %v", err)
}
addr := CoverAddressForBytes([]byte("old-art"))
if err := store.RemoveSeries("asura", "solo"); err != nil {
t.Fatalf("RemoveSeries: %v", err)
}
// The caller's sequence: the row is deleted first, then the address is
// reclaimed — the guard cannot pass while the row still points at it.
if err := store.ReclaimCover(addr); err != nil {
t.Fatalf("ReclaimCover: %v", err)
}
var one int
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&one); err != sql.ErrNoRows {
t.Fatalf("series row after remove = %v, want sql.ErrNoRows", err)
}
if _, _, ok, err := store.CoverByAddress(addr); err != nil || ok {
t.Fatalf("covers row after remove = found %v err %v, want gone", ok, err)
}
if _, err := os.Stat(coverShardPath(t, store, addr)); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("sharded file after remove = %v, want fs.ErrNotExist", err)
}
}
// The refusal is the whole point of the sentinel: a Series a Reader still
// holds is not removed, its row is untouched and its Cover keeps serving.
func TestRemoveSeriesRefusedWhileBookmarked(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/kept.jpg", []byte("kept-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover: %v", err)
}
addr := CoverAddressForBytes([]byte("kept-art"))
if err := store.RemoveSeries("asura", "solo"); !errors.Is(err, ErrSeriesHasBookmarks) {
t.Fatalf("RemoveSeries on a bookmarked series = %v, want ErrSeriesHasBookmarks", err)
}
var held int
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&held); err != nil {
t.Fatal("series row after refusal is gone; want it untouched")
}
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "kept-art" {
t.Fatalf("cover after refusal = found %v err %v, want still served", ok, err)
}
}
// A Series sharing its Cover address with a second Series is removed while
// the artwork stays readable through CoverByAddress: the series row stops
// referencing the address first, so ReclaimCover's guard passes for this
// caller without touching the shared bytes (ADR-0014).
func TestRemoveSeriesSparesSharedCover(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
t.Fatalf("orphan solo: %v", err)
}
if err := store.Delete(store.OwnerID(), "asura:second"); err != nil {
t.Fatalf("orphan second: %v", err)
}
const src = "https://cdn.example/covers/shared.jpg"
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover on solo: %v", err)
}
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
t.Fatalf("put cover on second: %v", err)
}
addr := CoverAddressForBytes([]byte("shared-art"))
if err := store.RemoveSeries("asura", "solo"); err != nil {
t.Fatalf("RemoveSeries: %v", err)
}
// The guard spares the shared bytes even though this caller reclaims.
if err := store.ReclaimCover(addr); err != nil {
t.Fatalf("ReclaimCover over a shared address: %v", err)
}
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
t.Fatalf("shared bytes after remove = found %v err %v, want still served", ok, err)
}
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
t.Fatalf("sharded file after remove: %v, want present", err)
}
var one int
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "second").Scan(&one); err != nil {
t.Fatal("the second series row vanished with the first")
}
}
// Deleting an absent key removes nothing and is not an error, matching the
// Delete precedent — the handler's own lookups turn the absent case into the
// 404 before the store ever sees it.
func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
store := newTestStore(t)
if err := store.RemoveSeries("asura", "ghost"); err != nil {
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
}
}