Latest Chapter correction: one numeric input, owner-gated (#149)

This commit is contained in:
2026-08-22 09:04:24 +07:00
parent 889f0f3f38
commit c9b1f2a334
10 changed files with 423 additions and 19 deletions
+10 -3
View File
@@ -41,7 +41,8 @@ type SeriesFilter struct {
// must never leave the store package — so the projection does not select it, // must never leave the store package — so the projection does not select it,
// and only the anonymous boolean in raisedByReaderAnswer crosses it. // and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at` s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
s.latest_corrected_at`
// raisedByReaderAnswer answers "did a Reader's report set this number" without // raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list — // naming which Reader. Kept apart from adminSeriesColumns so the column list —
@@ -72,8 +73,14 @@ type AdminSeries struct {
// meaning never asked. Pending is derived, never stored: a request is // meaning never asked. Pending is derived, never stored: a request is
// pending while ForcePollAt is newer than LatestCheckedAt. // pending while ForcePollAt is newer than LatestCheckedAt.
ForcePollAt int64 ForcePollAt int64
// LatestCorrectedAt is the correction stamp (issue #149): non-zero means
// the Latest Chapter is the owner's, zero means never corrected. The
// provenance line (#152) derives from it, so the zero-means-never meaning
// is load-bearing.
LatestCorrectedAt int64
ReaderCount int ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum RaisedByReader bool // a Reader's report set LatestChapterNum
} }
// SeriesPage is one page of the owner's filtered Series list plus the count // SeriesPage is one page of the owner's filtered Series list plus the count
@@ -189,7 +196,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
`+where+` `+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at, s.latest_raised_by s.force_poll_at, s.latest_corrected_at, s.latest_raised_by
`+having+` `+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
@@ -264,7 +271,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
if err := scan( if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt, &a.ForcePollAt, &a.LatestCorrectedAt,
&a.RaisedByReader, &a.ReaderCount, &total, &a.RaisedByReader, &a.ReaderCount, &total,
); err != nil { ); err != nil {
return AdminSeries{}, 0, err return AdminSeries{}, 0, err
@@ -0,0 +1,4 @@
-- latest_corrected_at is the "the current Latest Chapter is the owner's" stamp
-- (#149). Written by the Correction; zeroed by every machine write of the
-- value. Zero means never corrected.
ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0;
+35 -5
View File
@@ -904,6 +904,11 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
// xmax is zero only on a row this statement inserted, which is how a // xmax is zero only on a row this statement inserted, which is how a
// Series nobody had bookmarked before is told apart from one that already // Series nobody had bookmarked before is told apart from one that already
// existed — DO UPDATE returns a row either way. // existed — DO UPDATE returns a row either way.
// latest_corrected_at is the one clause conditional on the value moving
// (#149): after a Correction a Reader's cached row holds the corrected
// number and resends it on the next Progress PUT, so unconditional
// zeroing would erase the fact while the value is still the owner's. The
// stamp survives a same-number PUT and dies the moment the number moves.
var created bool var created bool
if err := tx.QueryRow(` if err := tx.QueryRow(`
INSERT INTO series (site, series_id, title, series_url, kind, INSERT INTO series (site, series_id, title, series_url, kind,
@@ -914,7 +919,10 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
ON CONFLICT (site, series_id) DO UPDATE SET ON CONFLICT (site, series_id) DO UPDATE SET
kind=excluded.kind, kind=excluded.kind,
latest_chapter=excluded.latest_chapter, latest_chapter=excluded.latest_chapter,
latest_chapter_num=excluded.latest_chapter_num latest_chapter_num=excluded.latest_chapter_num,
latest_corrected_at = CASE
WHEN series.latest_chapter_num IS DISTINCT FROM excluded.latest_chapter_num
THEN 0 ELSE series.latest_corrected_at END
RETURNING xmax = 0`, RETURNING xmax = 0`,
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
b.LatestChapter, latestNum).Scan(&created); err != nil { b.LatestChapter, latestNum).Scan(&created); err != nil {
@@ -1320,10 +1328,13 @@ func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) {
// SetLatestChapter records the newest chapter the poll found on a series page. // SetLatestChapter records the newest chapter the poll found on a series page.
// The poller walks Series rather than Bookmarks, so this is a series-level // The poller walks Series rather than Bookmarks, so this is a series-level
// write: the row is shared, and updating it once refreshes every bookmark that // write: the row is shared, and updating it once refreshes every bookmark that
// joins to it. Touching a missing series is not an error. // joins to it. Touching a missing series is not an error. The correction stamp
// is zeroed unconditionally: checkOne only calls this when the number differs,
// so a second copy of the condition would drift (#149).
func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error { func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error {
if _, err := s.db.Exec( if _, err := s.db.Exec(
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4 `UPDATE series SET latest_chapter = $3, latest_chapter_num = $4,
latest_corrected_at = 0
WHERE site = $1 AND series_id = $2`, WHERE site = $1 AND series_id = $2`,
site, seriesID, label, num); err != nil { site, seriesID, label, num); err != nil {
return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err) return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err)
@@ -1331,8 +1342,27 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
return nil return nil
} }
// RecordSighting notes that a Reader's browser reported this Series' Latest // CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE
// Chapter, which is the half of a Sighting the client body cannot express // carrying the number, the derived label and the correction stamp. The label
// shape is the poller's and the userscript's ("Chapter " + the number as
// printed), so chapterLeadIn strips it and the UI renders "Ch N" with no
// special case. latest_checked_at is not touched: a Correction is not a check.
// A raising Reader is cleared without judgement: the number is the owner's
// now, and no Sighting counter moves (spec #135).
func (s *Store) CorrectLatestChapter(site, seriesID string, num float64, at int64) error {
if _, err := s.db.Exec(
`UPDATE series SET
latest_chapter = $3,
latest_chapter_num = $4,
latest_corrected_at = $5,
latest_raised_by = NULL
WHERE site = $1 AND series_id = $2`,
site, seriesID, "Chapter "+strconv.FormatFloat(num, 'f', -1, 64), num, at); err != nil {
return fmt.Errorf("correct latest chapter %s:%s: %w", site, seriesID, err)
}
return nil
}
// (issue #103). It must be called *before* the Upsert that stores the reported // (issue #103). It must be called *before* the Upsert that stores the reported
// value: the raise test compares against what is still on the row, and after // value: the raise test compares against what is still on the row, and after
// the Upsert there is nothing left to compare with. A Series that does not // the Upsert there is nothing left to compare with. A Series that does not
+130
View File
@@ -1899,3 +1899,133 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due) t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
} }
} }
// A Correction writes the number, the derived label and the stamp, clears the
// raising Reader, and never touches either Sighting counter or the check
// stamp — a Correction is not a check and never judges a Reader (#149).
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
// A Reader raised the number, and carries a mark for it.
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
WHERE id = $1`, other); err != nil {
t.Fatalf("mark reader: %v", err)
}
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
var chapter string
var num float64
var stamp, checkedAt int64
var raisedBy any
if err := s.db.QueryRow(`
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
latest_checked_at, latest_raised_by
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
t.Fatalf("read back: %v", err)
}
if chapter != "Chapter 12.5" {
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
}
if num != 12.5 {
t.Errorf("latest_chapter_num = %v, want 12.5", num)
}
if stamp != 9000 {
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
}
if checkedAt != 4321_000 {
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
}
if raisedBy != nil {
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
}
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
}
}
}
// The stamp follows the number (spec #135): an Upsert resending the corrected
// value — a Reader's cached row after a correction — keeps it, and an Upsert
// that actually moves the number kills it. Unconditional zeroing would erase
// the fact while the value is still the owner's; that is the whole point of
// the clause.
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
s := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
}
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
// Same number back: the value is still the owner's.
same := base
same.LatestChapterNum = num2(5)
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
t.Fatalf("same-number upsert: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
}
// A different number: a machine (or a Reader) wrote the value.
moved := base
moved.LatestChapterNum = num2(7)
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
t.Fatalf("moved upsert: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
}
}
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
// calls it when the number differs, so the condition lives upstream and a
// second copy here would drift (#149).
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
t.Fatalf("SetLatestChapter: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
}
}
// latestCorrectedAt reads the stamp column for the assertion above.
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
t.Helper()
var stamp int64
if err := s.db.QueryRow(
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
site, seriesID).Scan(&stamp); err != nil {
t.Fatalf("read stamp: %v", err)
}
return stamp
}
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
func num2(f float64) *float64 { return &f }
+1
View File
@@ -47,6 +47,7 @@ func (h *Handler) adminRoutes() []adminRoute {
{"GET /admin/series", h.adminSeries}, {"GET /admin/series", h.adminSeries},
{"GET /admin/series/{key}", h.adminSeriesDetail}, {"GET /admin/series/{key}", h.adminSeriesDetail},
{"POST /admin/series/{key}/poll", h.adminSeriesPoll}, {"POST /admin/series/{key}/poll", h.adminSeriesPoll},
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
{"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},
+53
View File
@@ -3,6 +3,7 @@ package web
import ( import (
"fmt" "fmt"
"log" "log"
"math"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -172,6 +173,58 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
} }
// adminSeriesCorrectLatest is the Latest Chapter correction: the owner types
// one number and the Series' Latest Chapter becomes it, stamped as a
// Correction. The number must be a finite float greater than zero — a
// non-numeric, zero or negative value answers 400 and never reaches the
// store, because a bad value would become every Reader's problem. The press
// answers with the freshly rendered meta fragment, so the figures describe
// the state after the press. The owner gate is the route's, not this
// handler's; the body is capped like the API path caps its bodies.
func (h *Handler) adminSeriesCorrectLatest(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
}
num, err := strconv.ParseFloat(r.PostFormValue("chapter"), 64)
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 {
http.Error(w, "chapter must be a finite number greater than zero", http.StatusBadRequest)
return
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series correction %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else if !found {
http.NotFound(w, r)
return
}
if err := h.store.CorrectLatestChapter(site, seriesID, num, time.Now().UnixMilli()); err != nil {
log.Printf("series correction %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Re-read after the write: the answer must describe the state after the
// press, so the marker reads "corrected just now".
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series correction %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
}
// 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.
+15 -1
View File
@@ -24,6 +24,10 @@ type seriesDetailView struct {
Cover string Cover string
Chapter string // Latest Chapter number, or "—" before the first capture Chapter string // Latest Chapter number, or "—" before the first capture
Checked string // how long ago the poller last checked, or "never" Checked string // how long ago the poller last checked, or "never"
// Corrected is the correction marker's text, "" while no Correction
// stands: "corrected <age> ago" — the copy that says the value is the
// owner's, and it dies with the stamp (a machine write of the number).
Corrected string
Readers int Readers int
// Marks, one per hygiene fact, rendered only while it holds. // Marks, one per hygiene fact, rendered only while it holds.
@@ -31,7 +35,6 @@ type seriesDetailView struct {
NoCover bool NoCover bool
Orphan bool // no Reader holds the Series Orphan bool // no Reader holds the Series
SightingRaised bool // a Reader's Sighting set the Latest Chapter SightingRaised bool // a Reader's Sighting set the Latest Chapter
// Poll is the Check now control and the pending marker (issue #146): the // Poll is the Check now control and the pending marker (issue #146): the
// same derivation and visibility as the list row. CanPoll is false on a // same derivation and visibility as the list row. CanPoll is false on a
// Series with no page to fetch and on an orphan; Pending is derived — // Series with no page to fetch and on an orphan; Pending is derived —
@@ -122,5 +125,16 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
} else { } else {
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
} }
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
return v return v
} }
// correctedAge is the correction marker's text: "corrected <age> ago" while
// the stamp is set, "" when zero — zero means never corrected, and the marker
// must not read as history once a machine wrote the number.
func correctedAge(now time.Time, at int64) string {
if at == 0 {
return ""
}
return "corrected " + since(now, time.UnixMilli(at))
}
+21
View File
@@ -720,6 +720,27 @@
margin-top: 8px; margin-top: 8px;
} }
.admin-sheet .dform input {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--field-line);
background: var(--ink);
color: var(--paper);
font: 500 14px var(--font-mono);
outline: none;
}
/* Focus follows the chapter form's idiom — paper, not heat: a red border on
a valid number field reads as "invalid". */
.admin-sheet .dform input:focus {
border-color: var(--paper);
}
.admin-sheet .dform .hint {
margin: 0;
color: var(--mute-2);
font: 500 12px/1.4 var(--font-mono);
letter-spacing: .04em;
}
.admin-sheet .pausebar { .admin-sheet .pausebar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,8 +1,9 @@
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the {{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
list row is one hop from it. Everything here is a Series-level fact plus list row is one hop from it. Everything here is a Series-level fact plus
the anonymous Reader count. The Check now control lands in its own .dform the anonymous Reader count. The Check now control lands in its own .dform
below the (empty) .detail-grid; the pending marker rides the meta line below the .detail-grid; the correction form is the grid's first column,
with the other marks. */}} and the grid's second waits for the series-URL repair (#151). The pending
and corrected markers ride the meta line with the other marks. */}}
{{define "series-detail"}} {{define "series-detail"}}
<a class="ghost detail-back" href="/admin/series">← Series</a> <a class="ghost detail-back" href="/admin/series">← Series</a>
<h1 class="detail-title">{{.Title}}</h1> <h1 class="detail-title">{{.Title}}</h1>
@@ -10,7 +11,16 @@
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div> {{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
{{else}}<div class="cover"></div>{{end}} {{else}}<div class="cover"></div>{{end}}
{{template "series-detail-meta" .}} {{template "series-detail-meta" .}}
<div class="detail-grid"></div> <div class="detail-grid">
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML">
<h3>Correct latest chapter</h3>
<p class="hint">The next successful Poll overwrites this value.</p>
<div class="field">
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
<button type="submit" class="ghost">Set</button>
</div>
</form>
</div>
{{if .CanPoll}} {{if .CanPoll}}
<div class="dform"> <div class="dform">
<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>
@@ -18,14 +28,15 @@
{{end}} {{end}}
{{end}} {{end}}
{{/* series-detail-meta is the meta line, and the answer a Check now press on {{/* series-detail-meta is the meta line, and the answer a Check now or
the detail page swaps into its place: the same marks, re-rendered after correction press on the detail page swaps into its place: the same marks,
the stamp so the pending marker shows. */}} re-rendered after the stamp so the pending and corrected markers show. */}}
{{define "series-detail-meta"}} {{define "series-detail-meta"}}
<div class="detail-meta" id="detail-meta"> <div class="detail-meta" id="detail-meta">
<span>ch {{.Chapter}}</span> <span>ch {{.Chapter}}</span>
<span>checked {{.Checked}}</span> <span>checked {{.Checked}}</span>
<span>{{.Readers}} readers</span> <span>{{.Readers}} readers</span>
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}} {{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}} {{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
{{if .NoCover}}<span class="mark">no cover</span>{{end}} {{if .NoCover}}<span class="mark">no cover</span>{{end}}
+133
View File
@@ -3238,3 +3238,136 @@ 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,
// 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
// the stamp. The answer is the freshly rendered meta fragment, so the figures
// describe the state after the press (#149).
func TestCorrectLatestChapterRoute(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, latestNum: floatPtr(3),
})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
for _, body := range []string{
"chapter=abc", "chapter=", "chapter=0", "chapter=-1", "chapter=NaN", "chapter=Inf",
} {
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("POST latest with body %q: status = %d, want 400", body, rr.Code)
}
}
// Nothing reached the store: the seeded number stands, unstamped.
var num float64
var stamp int64
if err := db.QueryRow(`
SELECT latest_chapter_num, latest_corrected_at
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&num, &stamp); err != nil {
t.Fatalf("read back: %v", err)
}
if num != 3 || stamp != 0 {
t.Fatalf("after 400s the row is num %v, stamp %d; want 3, 0", num, stamp)
}
// A good press stores the number, the derived label and the stamp, and
// answers with the meta fragment describing the state after the press.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=12.5"))
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 latest status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("correction answer is not the meta fragment:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">corrected `) {
t.Errorf("correction answer lacks the fresh corrected marker:\n%s", body)
}
var label string
if err := db.QueryRow(`
SELECT latest_chapter, latest_chapter_num, latest_corrected_at
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&label, &num, &stamp); err != nil {
t.Fatalf("read back: %v", err)
}
if label != "Chapter 12.5" || num != 12.5 {
t.Errorf("stored = %q, %v; want the derived label and 12.5", label, num)
}
if stamp == 0 {
t.Error("stamp = 0, want the correction stamp written")
}
}
// The detail page offers the one-input correction with the plain copy, and
// the corrected marker rides the meta line while the stamp is set — then
// disappears the moment a machine writes the number (#149).
func TestAdminSeriesDetailCorrectionMarker(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)
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`name="chapter"`,
`hx-post="/admin/series/asura:solo/latest"`,
"The next successful Poll overwrites this value.",
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "corrected ") {
t.Errorf("uncorrected detail already carries the marker:\n%s", body)
}
// The press lands the marker on the meta line.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=7"))
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 latest status = %d, want 200", rr.Code)
}
body = seriesDetailPage(t, router, st, "asura:solo")
if !strings.Contains(body, `<span class="mark">corrected `) {
t.Errorf("detail page lacks the corrected marker after the press:\n%s", body)
}
if !strings.Contains(body, "ch 7") {
t.Errorf("detail page does not show the corrected number:\n%s", body)
}
// A machine write (the poller's setter) kills the marker.
if err := st.SetLatestChapter("asura", "solo", "Chapter 8", 8); err != nil {
t.Fatalf("SetLatestChapter: %v", err)
}
body = seriesDetailPage(t, router, st, "asura:solo")
if strings.Contains(body, "corrected ") {
t.Errorf("marker survives a machine write:\n%s", body)
}
if !strings.Contains(body, "ch 8") {
t.Errorf("detail page does not show the machine-written number:\n%s", body)
}
}