feat(backend): give every Bookmark an owner (Reader table) (#22)

A readers table appears, keyed by Discord user ID and carrying the SHA-256
of the owner's userscript token (the global API token today). Startup seeds
exactly one Reader from OWNER_DISCORD_ID, idempotently, and a run-once
migration (0004, version-table-gated) attaches existing bookmarks to it
before reshaping: the surrogate key column is dropped and bookmarks are
keyed (reader_id, site, series_id) with an FK to readers ON DELETE CASCADE,
so a duplicate bookmark for one Reader and Series is impossible at the
database level.

Every store read and write is now scoped to the reader it names; handlers
act as the seeded owner while the global token remains the only credential.
Authentication and the wire format are untouched: the flat JSON still
carries key/site/series_id, with key derived on read.

OWNER_DISCORD_ID is a new required env var (compose + docs updated).
This commit is contained in:
2026-08-08 07:59:40 +07:00
parent 984965ed9f
commit 3f7664ef9b
15 changed files with 553 additions and 164 deletions
+265 -50
View File
@@ -1,8 +1,11 @@
package store
import (
"bytes"
"crypto/sha256"
"database/sql"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -12,9 +15,13 @@ import (
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
// testOwner is the owner every test store seeds. Tests that need a second
// reader insert one directly (see secondReader).
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
func newTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(pgtest.URL(t))
store, err := Open(pgtest.URL(t), testOwner)
if err != nil {
t.Fatalf("Open: %v", err)
}
@@ -22,29 +29,44 @@ func newTestStore(t *testing.T) *Store {
return store
}
// secondReader inserts an extra reader row and returns its id. The store API
// has no reader-creation path yet — the seed is the only one — so tests that
// need reader isolation insert directly.
func secondReader(t *testing.T, s *Store) int64 {
t.Helper()
hash := sha256.Sum256([]byte("second-token-hash"))
var id int64
if err := s.db.QueryRow(
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
"second-"+strconv.FormatInt(time.Now().UnixNano(), 10), hash[:]).Scan(&id); err != nil {
t.Fatalf("seed 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)
first, err := Open(url)
first, err := Open(url, testOwner)
if err != nil {
t.Fatalf("Open: %v", err)
}
if _, err := first.Upsert(Bookmark{
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)
second, err := Open(url, testOwner)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { second.Close() })
list, err := second.List()
list, err := second.List(second.OwnerID())
if err != nil {
t.Fatalf("List: %v", err)
}
@@ -55,14 +77,14 @@ func TestOpenIsIdempotent(t *testing.T) {
func TestStoreGet(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(Bookmark{
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("asura:solo")
got, ok, err := store.Get(store.OwnerID(), "asura:solo")
if err != nil {
t.Fatalf("Get: %v", err)
}
@@ -76,7 +98,7 @@ func TestStoreGet(t *testing.T) {
func TestStoreGetMissing(t *testing.T) {
store := newTestStore(t)
_, ok, err := store.Get("asura:nope")
_, ok, err := store.Get(store.OwnerID(), "asura:nope")
if err != nil {
t.Fatalf("Get missing returned error %v, want nil", err)
}
@@ -151,7 +173,7 @@ func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
if _, err := s.Upsert(Bookmark{
if _, err := s.Upsert(s.OwnerID(), Bookmark{
Key: key,
Site: site,
SeriesID: seriesID,
@@ -239,12 +261,12 @@ func TestUpsertPreservesLatestCheckedAt(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 999)
b, ok, err := s.Get("asura:x")
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(b); err != nil {
if _, err := s.Upsert(s.OwnerID(), b); err != nil {
t.Fatalf("Upsert: %v", err)
}
if got := readLatestCheckedAt(t, s, "asura:x"); got != 999 {
@@ -254,7 +276,7 @@ func TestUpsertPreservesLatestCheckedAt(t *testing.T) {
func TestUpsertDefaultsStatusToReading(t *testing.T) {
store := newTestStore(t)
stored, err := store.Upsert(Bookmark{
stored, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
UpdatedAt: time.Now().UnixMilli(),
})
@@ -274,13 +296,13 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(base); err != nil {
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
base.Status = ""
base.LastChapterNum = 12
stored, err := store.Upsert(base)
stored, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -299,11 +321,11 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(base); err != nil {
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
cur, found, err := store.Get(base.Key)
cur, found, err := store.Get(store.OwnerID(), base.Key)
if err != nil || !found {
t.Fatalf("Get: found=%v err=%v", found, err)
}
@@ -313,7 +335,7 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
cur.LatestChapterNum = &num
cur.UpdatedAt = time.Now().UnixMilli()
stored, err := store.Upsert(cur)
stored, err := store.Upsert(store.OwnerID(), cur)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -328,12 +350,12 @@ func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(base); err != nil {
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
base.Status = StatusReading
stored, err := store.Upsert(base)
stored, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -350,14 +372,14 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
LastChapter: "45", LastChapterNum: 45,
UpdatedAt: time.Now().UnixMilli(),
}
first, err := store.Upsert(base)
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(base)
stored, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -375,7 +397,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
{"asura:archived", StatusArchived},
{"asura:finished", StatusFinished},
} {
if _, err := store.Upsert(Bookmark{
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(),
@@ -440,7 +462,7 @@ func TestDisplayChapter(t *testing.T) {
func TestUpsertKindDefaultsToManga(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(Bookmark{
got, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
})
if err != nil {
@@ -453,7 +475,7 @@ func TestUpsertKindDefaultsToManga(t *testing.T) {
func TestUpsertKindRoundTrips(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(Bookmark{
got, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000,
})
@@ -469,14 +491,14 @@ func TestUpsertKindRoundTrips(t *testing.T) {
// must keep the stored library, not silently demote a novel to manga.
func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(Bookmark{
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(Bookmark{
got, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000,
})
@@ -528,10 +550,14 @@ func TestMigration0002BackfillsExistingBookmarks(t *testing.T) {
t.Fatalf("seed legacy row: %v", err)
}
// Bring it current: 0002 must backfill the series row, not lose data.
if err := migrate(db); err != nil {
t.Fatalf("migrate: %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)
if err != nil {
t.Fatalf("Open after migrate: %v", err)
}
defer st.Close()
var (
title string
checked int64
@@ -545,20 +571,15 @@ func TestMigration0002BackfillsExistingBookmarks(t *testing.T) {
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 key = 'asura:solo'`).Scan(&fav, &lastNum); err != nil {
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)
}
// The migrated database opens as a normal store.
st, err := Open(url)
if err != nil {
t.Fatalf("Open after migrate: %v", err)
}
defer st.Close()
}
// readSeries reads the series row directly, for asserting on what Upsert
@@ -578,7 +599,7 @@ func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
// and URL — there is no other source for them (ADR-0003).
func TestUpsertCreatesSeriesFromClient(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(Bookmark{
if _, 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,
@@ -604,7 +625,7 @@ func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
Cover: "https://asurascans.com/covers/solo.jpg", LastChapterNum: 10,
UpdatedAt: 1000,
}
if _, err := store.Upsert(base); err != nil {
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
@@ -613,7 +634,7 @@ func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
base.SeriesURL = "https://evil.example/solo"
base.Cover = "https://evil.example/solo.jpg"
base.LastChapterNum = 11
got, err := store.Upsert(base)
got, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -635,7 +656,7 @@ func TestUpsertExistingSeriesAcceptsKindAndLatest(t *testing.T) {
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
UpdatedAt: 1000,
}
if _, err := store.Upsert(base); err != nil {
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
@@ -643,7 +664,7 @@ func TestUpsertExistingSeriesAcceptsKindAndLatest(t *testing.T) {
base.Kind = KindNovel
base.LatestChapter = "Chapter 12"
base.LatestChapterNum = &num
got, err := store.Upsert(base)
got, err := store.Upsert(store.OwnerID(), base)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -657,14 +678,14 @@ func TestUpsertExistingSeriesAcceptsKindAndLatest(t *testing.T) {
// re-bookmark shows title and cover immediately instead of waiting for a poll.
func TestDeleteKeepsSeriesRow(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(Bookmark{
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", Cover: "https://asurascans.com/covers/solo.jpg",
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := store.Delete("asura:solo"); err != nil {
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
t.Fatalf("Delete: %v", err)
}
@@ -674,7 +695,7 @@ func TestDeleteKeepsSeriesRow(t *testing.T) {
}
// Re-bookmark with nothing but progress: the stored title/cover come back.
stored, err := store.Upsert(Bookmark{
stored, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
LastChapterNum: 5, UpdatedAt: 2000,
})
@@ -686,13 +707,12 @@ func TestDeleteKeepsSeriesRow(t *testing.T) {
}
}
// seedSecondReader inserts an extra bookmark on an existing series. Today the
// bookmark key is <site>:<series_id>, so two bookmarks can share a series only
// once keys stop being derived from the series identity (issue #22); the due
// queue's reader-count ordering must already be right for that world.
// 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(Bookmark{
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)
@@ -751,3 +771,198 @@ func TestDueForLatestCheckExcludesOrphanSeries(t *testing.T) {
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)
first, err := Open(url, Owner{DiscordID: "owner", TokenHash: sha256.Sum256([]byte("hash-v1"))})
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"))})
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)
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)
}
}