Owner Finish control on the Series detail page: confirm-gated finish, instant un-finish (#158)

This commit is contained in:
2026-08-22 17:06:45 +07:00
parent 77e3f710ec
commit 948ecae2cc
10 changed files with 390 additions and 7 deletions
+163
View File
@@ -3428,6 +3428,169 @@ func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
}
}
// The finish control lives on the Series detail page only: a neighbouring
// Finish in the fifty-row grid is not a harmless read the way Check now is,
// so the list row renders the state as a mark and offers no control to set
// it. On the detail page the press is confirm-gated — the opener does not
// post and the only finish POST sits inside the confirm row, targeting the
// meta fragment it will swap (#158).
func TestAdminSeriesDetailFinishControl(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo", Kind: store.KindManga,
})
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`id="confirm-finish"`,
"Mark this Series finished?",
`hx-post="/admin/series/asura:solo/finish"`,
`hx-target="#detail-meta"`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
// The press travels through the confirm row: the page's only finish POST
// sits after the row's id (inside the row), and the opener that reveals
// it posts nothing.
if opener := strings.Index(body, `id="confirm-finish"`); opener < 0 ||
strings.Index(body, `hx-post="/admin/series/asura:solo/finish"`) < opener {
t.Errorf("finish POST is not inside the confirm row:\n%s", body)
}
if strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`) != 1 {
t.Errorf("finish POST count = %d, want exactly one (the affirmative):\n%s",
strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`), body)
}
// The list row displays nothing to set: no finish control, no finished
// mark on an unfinished Series.
list := adminSeriesPage(t, router, st, "")
for _, banned := range []string{
`/admin/series/asura:solo/finish`,
`/admin/series/asura:solo/unfinish`,
`mark-faint">finished`,
} {
if strings.Contains(list, banned) {
t.Errorf("list row carries %q:\n%s", banned, list)
}
}
}
// The finish route is the owner's one writer: a finish press stamps
// finished_at and answers with the swapped detail-meta fragment carrying the
// "finished <age> ago" mark, and an un-finish press writes zero and answers
// with a fragment that has no finished line. A malformed key is a 400 and an
// unknown one a 404, like the other Series mutations (#158).
func TestSeriesFinishRoute(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:solo", url: "u", checkedAt: 9000, bookmarks: 1})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
for path, want := range map[string]int{
"/admin/series/solo/finish": http.StatusBadRequest,
"/admin/series/ghost:x/finish": http.StatusNotFound,
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != want {
t.Errorf("POST %s: status = %d, want %d", path, rr.Code, want)
}
}
// A finish press stamps and answers with the swapped meta fragment whose
// mark reads "finished just now".
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/finish", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST finish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("finish answer is not the meta fragment:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">finished `) {
t.Errorf("finish answer lacks the fresh finished mark:\n%s", body)
}
var at int64
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
t.Fatalf("read finished_at: %v", err)
}
if at == 0 {
t.Error("finished_at = 0, want the finish stamp written")
}
// The page after the press offers the instant reversal, no confirm.
body = seriesDetailPage(t, router, st, "asura:solo")
if !strings.Contains(body, `hx-post="/admin/series/asura:solo/unfinish"`) {
t.Errorf("finished detail page lacks the un-finish control:\n%s", body)
}
if strings.Contains(body, "Mark this Series finished?") {
t.Errorf("finished detail page still carries the confirm row:\n%s", body)
}
// An un-finish press fires instantly and writes zero.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/unfinish", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST unfinish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body = rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("unfinish answer is not the meta fragment:\n%s", body)
}
if strings.Contains(body, `<span class="mark">finished `) {
t.Errorf("unfinish answer still carries the finished mark:\n%s", body)
}
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
t.Fatalf("read finished_at after un-finish: %v", err)
}
if at != 0 {
t.Errorf("finished_at = %d after un-finish, want 0", at)
}
}
// A finished Series shows its state in the list row as a faint mark, and the
// row offers no control to set or clear it — the reversal lives on the
// detail page, its press, and the mark is the whole of the row's share
// (#158).
func TestAdminSeriesListShowsFinished(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo", Kind: store.KindManga,
})
if err := st.SetSeriesFinished("asura", "solo", time.Now().Add(-2*time.Minute).UnixMilli()); err != nil {
t.Fatalf("SetSeriesFinished: %v", err)
}
body := adminSeriesPage(t, router, st, "")
if !strings.Contains(body, `<span class="mark mark-faint">finished</span>`) {
t.Errorf("list row lacks the finished mark:\n%s", body)
}
for _, banned := range []string{"solo/finish", "solo/unfinish"} {
if strings.Contains(body, banned) {
t.Errorf("list row offers %q:\n%s", banned, body)
}
}
}
// The series URL repair validates with the poller's own fetch gate and
// answers 400 before anything reaches the store; a URL that passes the gate
// is stored where an Upsert would have ignored it. The request performs no