diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index 7e78924..376c3e1 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -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 — @@ -71,9 +72,15 @@ type AdminSeries struct { // ForcePollAt is the owner's "check now" request stamp (issue #146), zero // meaning never asked. Pending is derived, never stored: a request is // pending while ForcePollAt is newer than LatestCheckedAt. - ForcePollAt int64 - ReaderCount int - RaisedByReader bool // a Reader's report set LatestChapterNum + 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 diff --git a/backend/internal/store/migrations/0015_latest_correction.sql b/backend/internal/store/migrations/0015_latest_correction.sql new file mode 100644 index 0000000..918ce61 --- /dev/null +++ b/backend/internal/store/migrations/0015_latest_correction.sql @@ -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; \ No newline at end of file diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index da4dd0d..cd519a9 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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 diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 4aa3b9d..8f17670 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -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 } diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index ca39e8c..bb22fe7 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -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}, diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index e9099ad..ef536aa 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -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. diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 74810aa..d905906 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -24,14 +24,17 @@ 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" - Readers int + // Corrected is the correction marker's text, "" while no Correction + // stands: "corrected 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. Unpollable bool // no SeriesURL to fetch 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 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)) +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index eb54abd..dd4f23e 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -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; diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index 3866e16..50cc2fb 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -1,8 +1,9 @@ {{/* Per-Series page: one address per Series, keyed ":" 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"}} ← Series

{{.Title}}

@@ -10,7 +11,16 @@ {{if .Cover}}
{{else}}
{{end}} {{template "series-detail-meta" .}} -
+
+
+

Correct latest chapter

+

The next successful Poll overwrites this value.

+
+ + +
+
+
{{if .CanPoll}}
@@ -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"}}
ch {{.Chapter}} checked {{.Checked}} {{.Readers}} readers + {{if .Corrected}}{{.Corrected}}{{end}} {{if .Pending}}{{.Requested}}{{end}} {{if .Unpollable}}unpollable{{end}} {{if .NoCover}}no cover{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index 3d701f5..608ab00 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -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, `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, `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) + } +}