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.
This commit is contained in:
2026-08-22 09:55:01 +07:00
parent ba2b6eebbb
commit bc64a1d894
9 changed files with 580 additions and 12 deletions
+109
View File
@@ -2340,3 +2340,112 @@ func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) {
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)
}
}