Spec #135: owner data-correction actions — Latest Chapter, series_url, Cover, orphan removal #156
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
@@ -1082,6 +1083,37 @@ func (s *Store) Delete(readerID int64, key string) error {
|
||||
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
|
||||
// transaction. retainBefore is supplied by the poller's clock.
|
||||
func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func (h *Handler) adminRoutes() []adminRoute {
|
||||
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
||||
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
|
||||
{"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}/resume", h.adminLaneResume},
|
||||
{"GET /ui/admin/lanes", h.uiLanes},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
@@ -48,7 +49,6 @@ var seriesFilterOrder = []string{
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Filters []seriesFilterOption
|
||||
Sites []string
|
||||
@@ -57,6 +57,9 @@ type seriesListView struct {
|
||||
FilterLabel string
|
||||
Rows []seriesRowView
|
||||
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
|
||||
// PrevHref / NextHref the pager's, all carrying the active filter, Site
|
||||
// and Kind so narrowing never drops state.
|
||||
@@ -101,6 +104,12 @@ type seriesRowView struct {
|
||||
CanPoll bool
|
||||
Pending bool
|
||||
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,
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
@@ -403,6 +569,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
Readers: a.ReaderCount,
|
||||
Band: i%2 == 1,
|
||||
CanPoll: canPoll,
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ type seriesDetailView struct {
|
||||
CanPoll bool
|
||||
Pending bool
|
||||
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
|
||||
@@ -122,6 +126,7 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
Orphan: a.ReaderCount == 0,
|
||||
SightingRaised: a.RaisedByReader,
|
||||
CanPoll: canPoll,
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
}
|
||||
|
||||
@@ -653,6 +653,13 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
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>
|
||||
{{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}}
|
||||
|
||||
{{/* 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
|
||||
Library segment links and the pager preserve the filter and Site. */}}
|
||||
{{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}}">
|
||||
<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}}
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
|
||||
</span>
|
||||
</form>
|
||||
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||
{{template "series-list-head" .}}
|
||||
{{if .Rows}}
|
||||
<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>
|
||||
@@ -48,6 +48,16 @@
|
||||
<span>{{.Age}}</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-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>
|
||||
{{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
@@ -2557,18 +2557,21 @@ func TestSeriesListRowShape(t *testing.T) {
|
||||
if !strings.Contains(body, `class="c-site site-asura"`) {
|
||||
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
|
||||
// empty on the orphan (no URL, no Readers — a button that can never do
|
||||
// anything is not offered), and no Remove control or confirm row renders
|
||||
// in this batch.
|
||||
if !strings.Contains(body, `<span class="c-act"></span>`) {
|
||||
t.Errorf("the orphan's action cell is not present and empty:\n%s", body)
|
||||
// The action cell carries the Check now control on pollable rows and the
|
||||
// Remove control on the orphan (#155) — a Series no Reader holds can be
|
||||
// removed, so the orphan's cell is never empty. No confirm row renders
|
||||
// in this batch: the confirm gate is htmx's own, not a toggled cell.
|
||||
if !strings.Contains(body, `<span class="c-act">`) {
|
||||
t.Errorf("the action cell is not present:\n%s", body)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
|
||||
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
|
||||
if got := strings.Count(body, ">Remove<"); got != 1 {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// The correction route validates at the boundary: a non-numeric, zero,
|
||||
// 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
|
||||
@@ -3546,3 +3550,231 @@ func TestAdminSeriesDetailRepairForm(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user