Merge ticket/146-forced-poll
This commit is contained in:
+202
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user