Latest Chapter correction: one numeric input, owner-gated (#149)
This commit is contained in:
@@ -41,7 +41,8 @@ type SeriesFilter struct {
|
||||
// must never leave the store package — so the projection does not select 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,
|
||||
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
|
||||
// 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
|
||||
// pending while ForcePollAt is newer than LatestCheckedAt.
|
||||
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
|
||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||
|
||||
}
|
||||
|
||||
// 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+`
|
||||
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.force_poll_at, s.latest_raised_by
|
||||
s.force_poll_at, s.latest_corrected_at, s.latest_raised_by
|
||||
`+having+`
|
||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||
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(
|
||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||
&a.ForcePollAt,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt,
|
||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
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;
|
||||
@@ -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
|
||||
// Series nobody had bookmarked before is told apart from one that already
|
||||
// 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
|
||||
if err := tx.QueryRow(`
|
||||
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
|
||||
kind=excluded.kind,
|
||||
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`,
|
||||
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
|
||||
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.
|
||||
// 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
|
||||
// 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 {
|
||||
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`,
|
||||
site, seriesID, label, num); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// RecordSighting notes that a Reader's browser reported this Series' Latest
|
||||
// Chapter, which is the half of a Sighting the client body cannot express
|
||||
// CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -1899,3 +1899,133 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
||||
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 }
|
||||
|
||||
@@ -47,6 +47,7 @@ func (h *Handler) adminRoutes() []adminRoute {
|
||||
{"GET /admin/series", h.adminSeries},
|
||||
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||
{"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}/resume", h.adminLaneResume},
|
||||
{"GET /ui/admin/lanes", h.uiLanes},
|
||||
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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()))
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -24,6 +24,10 @@ type seriesDetailView struct {
|
||||
Cover string
|
||||
Chapter string // Latest Chapter number, or "—" before the first capture
|
||||
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
|
||||
|
||||
// Marks, one per hygiene fact, rendered only while it holds.
|
||||
@@ -31,7 +35,6 @@ type seriesDetailView struct {
|
||||
NoCover bool
|
||||
Orphan bool // no Reader holds the Series
|
||||
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||
}
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -720,6 +720,27 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{{/* 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
|
||||
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
|
||||
with the other marks. */}}
|
||||
below the .detail-grid; the correction form is the grid's first column,
|
||||
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"}}
|
||||
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||
<h1 class="detail-title">{{.Title}}</h1>
|
||||
@@ -10,7 +11,16 @@
|
||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||
{{else}}<div class="cover"></div>{{end}}
|
||||
{{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}}
|
||||
<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>
|
||||
@@ -18,14 +28,15 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now press on
|
||||
the detail page swaps into its place: the same marks, re-rendered after
|
||||
the stamp so the pending marker shows. */}}
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
||||
correction press on the detail page swaps into its place: the same marks,
|
||||
re-rendered after the stamp so the pending and corrected markers show. */}}
|
||||
{{define "series-detail-meta"}}
|
||||
<div class="detail-meta" id="detail-meta">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} readers</span>
|
||||
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
||||
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||
|
||||
@@ -3238,3 +3238,136 @@ 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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user