Spec #135: owner data-correction actions — Latest Chapter, series_url, Cover, orphan removal #156

Merged
sulthan merged 15 commits from spec-135 into main 2026-08-22 12:10:41 +07:00
9 changed files with 580 additions and 12 deletions
Showing only changes of commit bc64a1d894 - Show all commits
+32
View File
@@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
) )
@@ -1082,6 +1083,37 @@ func (s *Store) Delete(readerID int64, key string) error {
return nil return nil
} }
// pgForeignKeyViolation is the SQLSTATE the driver surfaces when a Bookmark
// row refuses a Series delete (bookmarks_series_fk). pgconn exports no named
// constant for it, so the store names it here.
const pgForeignKeyViolation = "23503"
// ErrSeriesHasBookmarks is RemoveSeries' refusal: a Reader still holds the
// Series, so the owner's removal must not reach past that record. The
// delete is the check — no NOT EXISTS pre-check that can race the insert —
// and the driver's foreign-key violation is translated here so no driver
// type escapes the store (issue #155).
var ErrSeriesHasBookmarks = errors.New("series has bookmarks")
// RemoveSeries deletes one Series row by (site, series_id). It is refused
// while any Bookmark references the row; deleting an absent key is not an
// error, matching Delete. The caller owns the stranded Cover: read the row's
// cover_address before the delete and call ReclaimCover after it — the
// helper's guard cannot pass while the series row still points at the
// address, so the order is the sequence, not a preference.
func (s *Store) RemoveSeries(site, seriesID string) error {
if _, err := s.db.Exec(
`DELETE FROM series WHERE site = $1 AND series_id = $2`,
site, seriesID); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgForeignKeyViolation {
return ErrSeriesHasBookmarks
}
return fmt.Errorf("remove series %s:%s: %w", site, seriesID, err)
}
return nil
}
// RecordLanePass appends one pass and prunes every older row in the same // RecordLanePass appends one pass and prunes every older row in the same
// transaction. retainBefore is supplied by the poller's clock. // transaction. retainBefore is supplied by the poller's clock.
func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error { func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
+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") 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)
}
}
+1
View File
@@ -49,6 +49,7 @@ func (h *Handler) adminRoutes() []adminRoute {
{"POST /admin/series/{key}/poll", h.adminSeriesPoll}, {"POST /admin/series/{key}/poll", h.adminSeriesPoll},
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest}, {"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL}, {"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
{"POST /admin/series/{key}/remove", h.adminSeriesRemove},
{"POST /admin/lanes/{site}/pause", h.adminLanePause}, {"POST /admin/lanes/{site}/pause", h.adminLanePause},
{"POST /admin/lanes/{site}/resume", h.adminLaneResume}, {"POST /admin/lanes/{site}/resume", h.adminLaneResume},
{"GET /ui/admin/lanes", h.uiLanes}, {"GET /ui/admin/lanes", h.uiLanes},
+168 -1
View File
@@ -1,6 +1,7 @@
package web package web
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"math" "math"
@@ -48,7 +49,6 @@ var seriesFilterOrder = []string{
} }
// seriesListView is the Series list page's data. The template renders strings // seriesListView is the Series list page's data. The template renders strings
// and flags, and every judgement about what a value means is made here.
type seriesListView struct { type seriesListView struct {
Filters []seriesFilterOption Filters []seriesFilterOption
Sites []string Sites []string
@@ -57,6 +57,9 @@ type seriesListView struct {
FilterLabel string FilterLabel string
Rows []seriesRowView Rows []seriesRowView
Total int Total int
// OOB marks the out-of-band copy of the heading the removal answer
// carries; on the page itself it is false (issue #155).
OOB bool
// KindBoth / KindManga / KindNovel are the Library segment links, and // KindBoth / KindManga / KindNovel are the Library segment links, and
// PrevHref / NextHref the pager's, all carrying the active filter, Site // PrevHref / NextHref the pager's, all carrying the active filter, Site
// and Kind so narrowing never drops state. // and Kind so narrowing never drops state.
@@ -101,6 +104,12 @@ type seriesRowView struct {
CanPoll bool CanPoll bool
Pending bool Pending bool
Requested string // "requested 3m ago", rendered only while pending Requested string // "requested 3m ago", rendered only while pending
// CanRemove is the Remove control's visibility: only a Series no Reader
// holds can be removed, so the owner is never offered a button that the
// database will always refuse (issue #155). RemovalRefused marks the one
// raced answer: the row stays and says a fresh Bookmark caught the press.
CanRemove bool
RemovalRefused bool
} }
// adminSeries renders the filterable, bookmarkable Series list: filter, Site, // adminSeries renders the filterable, bookmarkable Series list: filter, Site,
@@ -279,6 +288,163 @@ func (h *Handler) adminSeriesSetURL(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
} }
// seriesListHeadView is the list heading's data. The template renders it
// inline at the top of the Series list and out of band in the removal answer
// (OOB true, like the chrome partials' OOB flag): the count and the filter
// label are one fact (issue #155).
type seriesListHeadView struct {
Total int
FilterLabel string
OOB bool
}
// adminSeriesRemove is the orphan removal: one Series, one delete, refused by
// the database while any Bookmark exists (translated by the store, never a
// driver error on the page). The owner gate is the route's, not this
// handler's; the body is capped like the API path caps its bodies; the key is
// validated here — a malformed key is a 400 and an unknown one a 404.
//
// The Cover is read from the row before the delete and reclaimed after it:
// ReclaimCover's guard cannot pass while a series row still points at the
// address, so the order is the sequence, not a preference. A reclamation
// failure is not a removal failure — the row is gone and the covers row
// survives for a retry; the handler logs and answers success, because the
// failure has no user-facing surface.
//
// Two callers, one handler, branched on HX-Target like adminSeriesPoll. The
// detail page's remove answers with a navigation — to the No-Readers list on
// success, back to the detail page when a fresh Bookmark raced the press,
// where the new count is visible. The list row's answers with the removed
// row's fragment and the heading re-rendered with the fresh count out of
// band; HX-Reswap deletes the row through the same button that swaps the
// refusal back in, and the count query runs over the press's own filter
// state, so the heading describes the list the owner is looking at.
func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.Error(w, "bad series key", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
// The row's Cover address is read before the delete because the delete is
// what makes it reclaimable.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series remove %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
cover := a.CoverAddress
if err := h.store.RemoveSeries(site, seriesID); err != nil {
if errors.Is(err, store.ErrSeriesHasBookmarks) {
// A Bookmark landed between the owner's read and the press: the
// row stays, answered at its new count with the fact spelled
// out — never a 500, and never a deleted row.
if r.Header.Get("HX-Target") == "detail-meta" {
seriesRemoveNavigation(w, r, "/admin/series/"+site+":"+seriesID)
return
}
fresh, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series remove %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
// A second press removed it while this one was refused; the
// row has nothing left to say.
http.NotFound(w, r)
return
}
band := 0
if r.PostFormValue("band") == "1" {
band = 1
}
row := seriesRow(fresh, band, time.Now())
row.RemovalRefused = true
h.render(w, http.StatusOK, "series-row", row)
return
}
log.Printf("series remove %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := h.store.ReclaimCover(cover); err != nil {
log.Printf("series remove %s: reclaim cover: %v", site+":"+seriesID, err)
}
if r.Header.Get("HX-Target") == "detail-meta" {
seriesRemoveNavigation(w, r, "/admin/series?filter="+store.SeriesFilterNoReaders)
return
}
// The list answer: the removed row's fragment, plus the heading
// re-rendered with the fresh count. HX-Reswap deletes the row through the
// same button that swaps the refusal back in. The count query failing
// does not undo the removal — log it and answer the row alone.
w.Header().Set("HX-Reswap", "delete")
band := 0
if r.PostFormValue("band") == "1" {
band = 1
}
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
if head, err := h.seriesListHeadView(r); err != nil {
log.Printf("series remove %s: %v", site+":"+seriesID, err)
} else {
h.render(w, http.StatusOK, "series-list-head", head)
}
}
// seriesListHeadView is the list heading with the count as it stands after a
// removal: the same filter, Site and Kind the press's row carried (the list
// row's button hx-includes the filterbar), so the figure describes the list
// the owner is looking at — the All filter and an unknown one stay the
// absent case. The count is the store's window total, one query.
func (h *Handler) seriesListHeadView(r *http.Request) (seriesListHeadView, error) {
filter := r.PostFormValue("filter")
if _, ok := seriesFilterLabels[filter]; !ok {
filter = store.SeriesFilterAll
}
site := r.PostFormValue("site")
kind := r.PostFormValue("kind")
if kind != store.KindManga && kind != store.KindNovel {
kind = ""
}
data, err := h.store.SeriesPage(store.SeriesFilter{
Site: site,
Kind: kind,
Name: filter,
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
Page: 1,
})
if err != nil {
return seriesListHeadView{}, err
}
return seriesListHeadView{
Total: data.Total,
FilterLabel: seriesFilterLabels[filter],
OOB: true,
}, nil
}
// seriesRemoveNavigation answers a removal from the detail page. htmx gets a
// full navigation (HX-Redirect): a bare 303 would be followed by the request
// and the landing page swapped into the press's target, so the header is the
// redirect htmx can see; plain clients get the 303 the ticket names.
func seriesRemoveNavigation(w http.ResponseWriter, r *http.Request, to string) {
if r.Header.Get("HX-Request") != "" {
w.Header().Set("HX-Redirect", to)
return
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
// seriesListView assembles one Series list view from the request's query // seriesListView assembles one Series list view from the request's query
// string. An unknown filter value is the absent All case, never an error: the // string. An unknown filter value is the absent All case, never an error: the
// select's options are not the only way this URL can be reached. // select's options are not the only way this URL can be reached.
@@ -403,6 +569,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
Readers: a.ReaderCount, Readers: a.ReaderCount,
Band: i%2 == 1, Band: i%2 == 1,
CanPoll: canPoll, CanPoll: canPoll,
CanRemove: a.ReaderCount == 0,
Pending: pending, Pending: pending,
Requested: requested, Requested: requested,
} }
@@ -52,6 +52,10 @@ type seriesDetailView struct {
CanPoll bool CanPoll bool
Pending bool Pending bool
Requested string Requested string
// CanRemove is the Remove control's visibility (issue #155): only a
// Series no Reader holds can be removed, so the owner is never offered a
// button the database will always refuse.
CanRemove bool
} }
// adminSeriesDetail renders one Series' page, keyed by the composite // adminSeriesDetail renders one Series' page, keyed by the composite
@@ -122,6 +126,7 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
Orphan: a.ReaderCount == 0, Orphan: a.ReaderCount == 0,
SightingRaised: a.RaisedByReader, SightingRaised: a.RaisedByReader,
CanPoll: canPoll, CanPoll: canPoll,
CanRemove: a.ReaderCount == 0,
Pending: pending, Pending: pending,
Requested: requested, Requested: requested,
} }
+7
View File
@@ -653,6 +653,13 @@
margin-left: auto; margin-left: auto;
} }
.admin-sheet .tbl.series .row-msg {
grid-column: 1 / -1;
margin-top: 6px;
color: var(--danger-soft);
font: 400 13px/1.4 var(--font-body);
}
.admin-sheet .detail-back { .admin-sheet .detail-back {
display: inline-block; display: inline-block;
margin: 18px 0 0; margin: 18px 0 0;
@@ -34,6 +34,11 @@
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div> <div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
</div> </div>
{{end}} {{end}}
{{if .CanRemove}}
<div class="dform">
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
</div>
{{end}}
{{end}} {{end}}
{{/* series-detail-meta is the meta line, and the answer a Check now or {{/* series-detail-meta is the meta line, and the answer a Check now or
@@ -4,7 +4,7 @@
that can be bookmarked: the two selects submit the GET form, and the that can be bookmarked: the two selects submit the GET form, and the
Library segment links and the pager preserve the filter and Site. */}} Library segment links and the pager preserve the filter and Site. */}}
{{define "series-list"}} {{define "series-list"}}
<form class="filterbar" method="get" action="/admin/series"> <form class="filterbar" id="filterbar" method="get" action="/admin/series">
<input type="hidden" name="kind" value="{{.Kind}}"> <input type="hidden" name="kind" value="{{.Kind}}">
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()"> <label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}} {{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
@@ -19,7 +19,7 @@
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a> <a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
</span> </span>
</form> </form>
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div> {{template "series-list-head" .}}
{{if .Rows}} {{if .Rows}}
<div class="tbl series"> <div class="tbl series">
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div> <div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
@@ -48,6 +48,16 @@
<span>{{.Age}}</span> <span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span> <span class="c-rd">{{.Readers}}</span>
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span> <span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}</span> <span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
</div> </div>
{{end}} {{end}}
{{/* series-list-head is the list's heading — the count and the label are one
fact. The removal answer renders it out of band (the OOB flag, like the
chrome partials) so the heading never lies past the row that made it,
and inline here it is the page's own heading. The id is the OOB swap's
hook; hx-swap-oob sits on the element the answer carries. */}}
{{define "series-list-head"}}
<div class="listhead" id="series-listhead"{{if .OOB}} hx-swap-oob="true"{{end}}>{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
{{end}}
+240 -8
View File
@@ -2557,18 +2557,21 @@ func TestSeriesListRowShape(t *testing.T) {
if !strings.Contains(body, `class="c-site site-asura"`) { if !strings.Contains(body, `class="c-site site-asura"`) {
t.Errorf("the site cell lacks its site class:\n%s", body) t.Errorf("the site cell lacks its site class:\n%s", body)
} }
// The action cell carries the Check now control on pollable rows and is // The action cell carries the Check now control on pollable rows and the
// empty on the orphan (no URL, no Readers — a button that can never do // Remove control on the orphan (#155) — a Series no Reader holds can be
// anything is not offered), and no Remove control or confirm row renders // removed, so the orphan's cell is never empty. No confirm row renders
// in this batch. // in this batch: the confirm gate is htmx's own, not a toggled cell.
if !strings.Contains(body, `<span class="c-act"></span>`) { if !strings.Contains(body, `<span class="c-act">`) {
t.Errorf("the orphan's action cell is not present and empty:\n%s", body) t.Errorf("the action cell is not present:\n%s", body)
} }
if got := strings.Count(body, "Check now"); got != 2 { if got := strings.Count(body, "Check now"); got != 2 {
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body) t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
} }
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") { if got := strings.Count(body, ">Remove<"); got != 1 {
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body) t.Errorf("Remove control count = %d, want 1 (only the orphan):\n%s", got, body)
}
if strings.Contains(body, "confirm-row") {
t.Errorf("a confirm row renders in this batch:\n%s", body)
} }
// No ember: the new-chapter signal stays off the admin surface. Scoped to // No ember: the new-chapter signal stays off the admin surface. Scoped to
// the page content — the shell's brand mark legitimately wears the ember // the page content — the shell's brand mark legitimately wears the ember
@@ -3292,6 +3295,7 @@ func TestSeriesPollCapsBody(t *testing.T) {
t.Errorf("an oversized body still stamped the request:\n%s", body) t.Errorf("an oversized body still stamped the request:\n%s", body)
} }
} }
// The correction route validates at the boundary: a non-numeric, zero, // The correction route validates at the boundary: a non-numeric, zero,
// negative or non-finite chapter answers 400 and never reaches the store, and // negative or non-finite chapter answers 400 and never reaches the store, and
// a finite number greater than zero stores the number, the derived label and // a finite number greater than zero stores the number, the derived label and
@@ -3546,3 +3550,231 @@ func TestAdminSeriesDetailRepairForm(t *testing.T) {
t.Errorf("repair input does not carry the escaped stored URL:\n%s", body) t.Errorf("repair input does not carry the escaped stored URL:\n%s", body)
} }
} }
// The Remove control is offered only to the owner, and only on a Series no
// Reader holds: on the orphan's list row and on the orphan's detail page,
// nowhere else (#155).
func TestSeriesRemoveRendersOnlyOnOrphans(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
body := adminSeriesPage(t, router, st, "")
if got := strings.Count(body, ">Remove<"); got != 1 {
t.Errorf("list offers Remove %d times, want 1 (only the orphan):\n%s", got, body)
}
for _, tc := range []struct {
key string
want bool
}{
{"asura:ok", false},
{"asura:orphan", true},
} {
body := seriesDetailPage(t, router, st, tc.key)
if got := strings.Contains(body, ">Remove<"); got != tc.want {
t.Errorf("%s detail offers Remove = %v, want %v", tc.key, got, tc.want)
}
}
}
// A removal from the list answers with the removed row's fragment and the
// heading re-rendered out of band with the fresh count: the row and the
// count are one fact. The row's press carries the list's filter state, so
// the count describes the list the owner is looking at, and the HX-Reswap
// header deletes the row through the same button that swaps the refusal in.
func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", checkedAt: 9000, bookmarks: 0})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:held", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:a/remove",
strings.NewReader("filter=no_readers&band=0"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("removal status = %d, want 200", rr.Code)
}
if got := rr.Header().Get("HX-Reswap"); got != "delete" {
t.Errorf("response does not ask htmx to delete the row (HX-Reswap = %q)", got)
}
body := rr.Body.String()
if !strings.Contains(body, "Title of asura:a") {
t.Errorf("answer does not carry the removed row's fragment:\n%s", body)
}
if !strings.Contains(body, `hx-swap-oob="true"`) ||
!strings.Contains(body, "1 series") || !strings.Contains(body, "No Readers") {
t.Errorf("answer does not re-render the heading out of band with the fresh count:\n%s", body)
}
// Gone from the store, gone from the list, and the heading lies no longer.
var one int
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'a'`).Scan(&one); err != sql.ErrNoRows {
t.Fatalf("series row after removal = %v, want sql.ErrNoRows", err)
}
body = adminSeriesPage(t, router, st, "?filter=no_readers")
if strings.Contains(body, "Title of asura:a") || !strings.Contains(body, "1 series") {
t.Errorf("list after removal is not the fresh view:\n%s", body)
}
}
// A removal from the detail page navigates to the No-Readers list: htmx gets
// a full navigation (HX-Redirect — a 303 would be followed by the request
// and the list page swapped into the press's target), plain clients the 303
// the ticket names, to the wire filter the orphan list actually is.
func TestRemoveFromDetailRedirectsToNoReadersList(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:gone", url: "u", checkedAt: 9000, bookmarks: 0})
// A second orphan for the htmx dialect's request, whose row must still
// exist after the first request removed its own.
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:gone2", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
target := "/admin/series?filter=" + store.SeriesFilterNoReaders
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:gone/remove", nil)
req.Header.Set("HX-Target", "detail-meta")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("detail removal status = %d, want 303", rr.Code)
}
if got := rr.Header().Get("Location"); got != target {
t.Errorf("Location = %q, want %q", got, target)
}
var one int
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'gone'`).Scan(&one); err != sql.ErrNoRows {
t.Fatalf("series row after detail removal = %v, want sql.ErrNoRows", err)
}
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:gone2/remove", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Target", "detail-meta")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if got := rr.Header().Get("HX-Redirect"); got != target {
t.Errorf("HX-Redirect = %q, want %q", got, target)
}
}
// A removal that races a fresh Bookmark is a refusal, not an error: the row
// is rendered again at its new count with the fact spelled out, never a 500,
// and it must not vanish from the list — the delete never happened. The
// detail-surface refusal navigates back to the detail page, where the same
// fresh count is visible.
func TestRemoveRacedBookmarkIsRefusedNotError(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:raced", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:raced/remove",
strings.NewReader("filter=no_readers&band=0"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("refusal status = %d, want 200 (never a 500)", rr.Code)
}
if got := rr.Header().Get("HX-Reswap"); got != "" {
t.Errorf("refusal carries HX-Reswap = %q, want none (the row must stay)", got)
}
body := rr.Body.String()
if !strings.Contains(body, "a Reader has bookmarked this Series again") {
t.Errorf("refusal does not say what happened:\n%s", body)
}
if !strings.Contains(body, `class="c-rd">1</span>`) {
t.Errorf("refusal does not render the fresh count:\n%s", body)
}
var one int
if err := db.QueryRow(`SELECT 1 FROM series WHERE site = 'asura' AND series_id = 'raced'`).Scan(&one); err != nil {
t.Fatalf("series row after refusal = %v, want present", err)
}
list := adminSeriesPage(t, router, st, "")
if !strings.Contains(list, "Title of asura:raced") {
t.Fatal("row vanished from the list after a refused removal")
}
if strings.Contains(list, ">Remove<") {
t.Errorf("a held series still offers Remove:\n%s", list)
}
// The detail-surface refusal navigates back to the detail page.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:raced/remove", nil)
req.Header.Set("HX-Target", "detail-meta")
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("detail refusal status = %d, want 303", rr.Code)
}
if got := rr.Header().Get("Location"); got != "/admin/series/asura:raced" {
t.Errorf("refusal Location = %q, want the detail page", got)
}
}
// The remove route trusts the same way the poll route does: a malformed key
// is a 400 and an unknown Series a 404, and an oversized body is a 400 that
// removes nothing.
func TestRemoveRejectsBadKeysAndCapsBody(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:x", Site: "asura", SeriesID: "x",
Title: "Title of asura:x", SeriesURL: "u",
})
for _, tc := range []struct {
path string
want int
}{
{"/admin/series/nocolon/remove", http.StatusBadRequest},
{"/admin/series/:x/remove", http.StatusBadRequest},
{"/admin/series/asura:/remove", http.StatusBadRequest},
{"/admin/series/asura:ghost/remove", http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want)
}
}
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/remove", strings.NewReader(big))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("oversized body status = %d, want 400", rr.Code)
}
list := adminSeriesPage(t, router, st, "")
if !strings.Contains(list, "Title of asura:x") {
t.Errorf("an oversized body still removed the row:\n%s", list)
}
}