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:
+17
-17
@@ -56,7 +56,7 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
||||
func TestIndexWithSessionShowsList(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
if _, err := st.Upsert(store.Bookmark{
|
||||
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
@@ -203,7 +203,7 @@ func TestStaticAssetsServed(t *testing.T) {
|
||||
// seed inserts one bookmark and returns it as stored.
|
||||
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
|
||||
t.Helper()
|
||||
stored, err := st.Upsert(b)
|
||||
stored, err := st.Upsert(st.OwnerID(), b)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
after, ok, err := st.Get("asura:solo")
|
||||
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
// Toggling again turns it back off.
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
||||
back, _, _ := st.Get("asura:solo")
|
||||
back, _, _ := st.Get(st.OwnerID(), "asura:solo")
|
||||
if back.Favorite {
|
||||
t.Fatal("Favorite = true after a second toggle, want false")
|
||||
}
|
||||
@@ -334,7 +334,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
after, ok, err := st.Get("asura:solo")
|
||||
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
||||
}
|
||||
@@ -374,7 +374,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
after, ok, err := st.Get("asura:solo")
|
||||
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
||||
}
|
||||
@@ -408,7 +408,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
after, _, _ := st.Get("asura:solo")
|
||||
after, _, _ := st.Get(st.OwnerID(), "asura:solo")
|
||||
if after.LastChapterNum != 45 {
|
||||
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
||||
}
|
||||
@@ -459,7 +459,7 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
||||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
||||
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
||||
}
|
||||
if _, ok, _ := st.Get("asura:solo"); ok {
|
||||
if _, ok, _ := st.Get(st.OwnerID(), "asura:solo"); ok {
|
||||
t.Fatal("row still present after delete")
|
||||
}
|
||||
}
|
||||
@@ -538,7 +538,7 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
||||
}
|
||||
for _, b := range rows {
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
if _, err := st.Upsert(b); err != nil {
|
||||
if _, err := st.Upsert(st.OwnerID(), b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
@@ -612,7 +612,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
|
||||
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if _, err := st.Upsert(caught); err != nil {
|
||||
if _, err := st.Upsert(st.OwnerID(), caught); err != nil {
|
||||
t.Fatalf("seed %s: %v", caught.Key, err)
|
||||
}
|
||||
|
||||
@@ -632,12 +632,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
}
|
||||
|
||||
// Nothing new anywhere: the strip has nothing to say and does not render.
|
||||
reading, _, err := st.Get("asura:reading")
|
||||
reading, _, err := st.Get(st.OwnerID(), "asura:reading")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
||||
if _, err := st.Upsert(reading); err != nil {
|
||||
if _, err := st.Upsert(st.OwnerID(), reading); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
// The section still ships (an out-of-band swap needs the id to exist) but
|
||||
@@ -662,7 +662,7 @@ func TestRecentStripCapped(t *testing.T) {
|
||||
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
|
||||
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
||||
}
|
||||
if _, err := st.Upsert(b); err != nil {
|
||||
if _, err := st.Upsert(st.OwnerID(), b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
@@ -692,7 +692,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
||||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||
}
|
||||
b, ok, err := st.Get("asura:reading")
|
||||
b, ok, err := st.Get(st.OwnerID(), "asura:reading")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
@@ -710,7 +710,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
b, _, _ := st.Get("asura:reading")
|
||||
b, _, _ := st.Get(st.OwnerID(), "asura:reading")
|
||||
if b.Status != store.StatusReading {
|
||||
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||||
}
|
||||
@@ -736,12 +736,12 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
before, _, _ := st.Get("asura:reading")
|
||||
before, _, _ := st.Get(st.OwnerID(), "asura:reading")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
after, _, _ := st.Get("asura:reading")
|
||||
after, _, _ := st.Get(st.OwnerID(), "asura:reading")
|
||||
if after.UpdatedAt != before.UpdatedAt {
|
||||
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user