Merge ticket/146-forced-poll

This commit is contained in:
2026-08-22 01:05:09 +07:00
14 changed files with 855 additions and 49 deletions
+202 -3
View File
@@ -2355,10 +2355,15 @@ 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 is present and empty for the sibling ticket, and no
// Remove control or confirm row renders in this batch.
// 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 action cell is not present and empty:\n%s", body)
t.Errorf("the orphan's action cell is not present and empty:\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)
@@ -2837,3 +2842,197 @@ func TestOverviewPerSiteTableLinksToNarrowedLists(t *testing.T) {
}
}
}
// seriesDetailPage drives one Series detail request as the owner and returns
// the rendered body.
func seriesDetailPage(t *testing.T, srv http.Handler, st *store.Store, key string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code)
}
return rr.Body.String()
}
// Pressing Check now answers with freshly rendered markup showing the pending
// marker: the list row (or the detail meta) re-rendered after the stamp, so
// the figures describe the state after the press, not before (issue #146).
func TestSeriesPollRoundTrip(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: "https://asurascans.com/comics/x",
})
// List surface: the row anchor swaps its own row.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `class="trow`) {
t.Errorf("poll response is not the freshly rendered row:\n%s", body)
}
if !strings.Contains(body, `<span class="mark">requested `) {
t.Errorf("poll response lacks the pending marker:\n%s", body)
}
// The press answers with the row's own band parity (hx-vals), so the swap
// keeps the zebra alternation.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader("band=1"))
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("POST poll (banded) status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `class="trow`) || !strings.Contains(rr.Body.String(), ` band"`) {
t.Errorf("banded press answer lost the zebra parity:\n%s", rr.Body.String())
}
// Detail surface: the meta fragment carries the same marker.
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
req.Header.Set("HX-Target", "detail-meta")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll (detail) status = %d, want 200", rr.Code)
}
body = rr.Body.String()
if !strings.Contains(body, `id="detail-meta"`) || !strings.Contains(body, "requested ") {
t.Errorf("detail response lacks the meta fragment with the pending marker:\n%s", body)
}
// The request is durable: the list shows the pending marker, which is the
// same row the press's answer rendered.
body = adminSeriesPage(t, router, st, "")
if !strings.Contains(body, "requested ") {
t.Errorf("the list does not show the pending marker after the press:\n%s", body)
}
}
// The Check now control is hidden on a Series with no page to fetch and on
// one no Reader holds — the owner is never offered a button that can never do
// anything — and present otherwise, on both the list row and the detail page.
func TestSeriesPollControlVisibility(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:nourl", checkedAt: 9000, bookmarks: 1})
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0})
router := newRouter(st, testConfig())
// List: exactly the pollable row offers the control.
body := adminSeriesPage(t, router, st, "")
if got := strings.Count(body, "Check now"); got != 1 {
t.Errorf("list offers Check now %d times, want 1 (only the pollable row):\n%s", got, body)
}
// Detail pages: the pollable row offers it, the other two do not.
for _, tc := range []struct {
key string
want bool
}{
{"asura:ok", true},
{"asura:nourl", false},
{"asura:orphan", false},
} {
body := seriesDetailPage(t, router, st, tc.key)
if got := strings.Contains(body, "Check now"); got != tc.want {
t.Errorf("%s detail offers Check now = %v, want %v", tc.key, got, tc.want)
}
}
}
// The pending marker ages and never expires: an old unanswered request still
// renders its marker with an old age, and a second press re-stamps the
// request time.
func TestSeriesPollMarkerAgesAndNeverExpires(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:x", url: "u", checkedAt: 0, bookmarks: 1})
// A five-day-old request, never checked: pending, with no expiry.
if _, err := db.Exec(
`UPDATE series SET force_poll_at = $1 WHERE site = 'asura' AND series_id = 'x'`,
time.Now().Add(-5*24*time.Hour).UnixMilli()); err != nil {
t.Fatalf("seed force stamp: %v", err)
}
router := newRouter(st, testConfig())
body := adminSeriesPage(t, router, st, "")
if !strings.Contains(body, "requested 5d ago") {
t.Errorf("old request does not render its aged marker:\n%s", body)
}
// A second press re-stamps: the marker reads fresh again.
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST poll status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "requested 1m ago") {
t.Errorf("re-stamp does not re-age the marker:\n%s", rr.Body.String())
}
}
// A malformed key is a 400 and an unknown key a 404 — neither is a 500, and
// neither reaches the store as an unvalidated write.
func TestSeriesPollRejectsBadKeys(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
for _, tc := range []struct {
path string
want int
}{
{"/admin/series/nocolon/poll", http.StatusBadRequest},
{"/admin/series/:x/poll", http.StatusBadRequest},
{"/admin/series/asura:/poll", http.StatusBadRequest},
{"/admin/series/asura:ghost/poll", 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)
}
}
}
// Form bodies on the action route are capped the way the API path caps them:
// an oversized body is a 400, not a memory grant.
func TestSeriesPollCapsBody(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",
})
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", 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)
}
// Nothing was stamped: the list still shows no pending marker.
if body := adminSeriesPage(t, router, st, ""); strings.Contains(body, "requested ") {
t.Errorf("an oversized body still stamped the request:\n%s", body)
}
}