Give every Bookmark an owner (Reader table) (#30)
Closes #22 ## What A `readers` table appears; every Bookmark belongs to one. The owner is seeded as the first and only Reader, and all existing rows are attached to them. - **Migration 0003**: `readers` (discord_id UNIQUE, token_sha256 UNIQUE, created_at). - **Migration 0004** (run-once, version-table-gated): attaches existing bookmarks to the seeded owner, drops the surrogate `key` column, composite PK `(reader_id, site, series_id)`, FK to readers `ON DELETE CASCADE` — a duplicate Bookmark for one Reader and Series is impossible at the database level. - **Seed**: `Store.Open` runs schema to 0003, seeds exactly one owner row from `OWNER_DISCORD_ID` (hash = SHA-256 of `API_TOKEN`, refreshed on every start so rotation stays current), then migrates the rest. - **Scoping**: `List/Get/Upsert/Delete` take `readerID`; the wire `key` is derived as `site:series_id` on read. Handlers act as `Store.OwnerID()` while the global token remains the only credential. - **Unchanged**: authentication and the flat wire format — nothing observable changes from outside. - **New env** `OWNER_DISCORD_ID` (required): compose, .env.example, DEPLOY.md, README.md, backend/AGENTS.md updated. Series-level methods (due queue, mark-checked, set-latest-chapter) stay unscoped deliberately: series are shared rows polled once per due cycle, and the reader_count ordering requires cross-reader visibility (ADR-0003). ## Verification - `go test ./...` green, including new tests: seed idempotency + hash refresh, 0004 attach migration, DB-level duplicate impossibility, per-reader scoping, reader-delete cascade. - Live smoke test on fresh Postgres: seed → PUT/GET (flat wire intact) → restart idempotent; stored hash matches SHA-256 of the token. ## Deploy note `OWNER_DISCORD_ID` is required after this lands — the backend refuses to start without it. Set it to the owner's Discord snowflake (Settings → Advanced → Developer Mode → right-click name → Copy User ID). Reviewed-on: #30 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user