feat(web,latest): pause and resume one Site's Lane with a mandatory expiry (#147)
Two owner-gated POST routes write the durable poll_lanes pause stamp the
poller's top-of-pass gate already reads: /admin/lanes/{site}/pause validates
the duration against the fixed 1h/6h/24h allow-list and the Site against the
registry, and /admin/lanes/{site}/resume zeroes the stamp. Both cap the form
body like the API path, answer with the freshly rendered Lanes block, and
never command the poller — the pause is a fact about the Site, so it
survives a restart. The Lanes page's c-ctrl slot now carries the pausebar:
Resume while paused, the duration select plus Pause while running, with the
paused phrase read from the live poll_lanes stamp so a press renders as
paused with no pass having run. Tests cover the round trips, rejections,
body caps, the pause-before-refusal ordering, fresh-poller survival, resume
restoring the full queue, and acquisition being unaffected.
This commit is contained in:
@@ -135,6 +135,33 @@ func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A pause governs the Lane only: a Reader's first bookmark of a Series on a
|
||||
// paused Site still reads the page, because acquisition is the creation-time
|
||||
// fetch, not the poll queue (issue #147).
|
||||
func TestAcquireIgnoresLanePause(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
if err := s.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200}
|
||||
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
||||
acq := newAcquirer(s, page, covers)
|
||||
|
||||
bookmarkNewSeries(t, s, acquireSeriesURL)
|
||||
acq.Wait()
|
||||
|
||||
if got := page.callCount(); got != 1 {
|
||||
t.Fatalf("series page fetches on a paused Site = %d, want 1", got)
|
||||
}
|
||||
if got := covers.callCount(); got != 1 {
|
||||
t.Fatalf("cover fetches = %d, want 1", got)
|
||||
}
|
||||
got := readBookmark(t, s, acquireKey)
|
||||
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
|
||||
t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series that already exists is not re-acquired: no fetch, and the Cover it
|
||||
// already has is left alone.
|
||||
func TestAcquireSkipsAnExistingSeries(t *testing.T) {
|
||||
|
||||
@@ -2058,6 +2058,107 @@ func TestDurableRefusalSurvivesFreshPoller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The pause is read ahead of the refusal check: a Lane that is both paused
|
||||
// and inside a refusal backoff records the paused skip value, not the
|
||||
// refusing one. The pause is the owner's order and outranks the Site's mood
|
||||
// (issue #147).
|
||||
func TestPauseGatePrecedesRefusalGate(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||
if err := s.SetLaneRefusal("asura", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("SetLaneRefusal: %v", err)
|
||||
}
|
||||
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
if pace := p.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||
t.Fatalf("paused-while-refusing pace = %s, want 30m (the pause's expiry)", pace)
|
||||
}
|
||||
if f.callCount() != 0 {
|
||||
t.Fatalf("fetches while paused and refusing = %d, want 0", f.callCount())
|
||||
}
|
||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||
t.Fatalf("skip = %q, want %q (the pause outranks the refusal)", pass.Skip, SkipPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// A pause is a fact about the Site, not about the process: a freshly
|
||||
// constructed poller against a store holding a pause row stays paused until
|
||||
// the expiry, then runs the Lane normally. The restart criterion is the
|
||||
// whole point of writing a row instead of commanding a poller (issue #147).
|
||||
func TestDurablePauseSurvivesFreshPoller(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
// A restart: a brand-new poller, no in-memory state, same store.
|
||||
fresh := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||
if pace := fresh.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||
t.Fatalf("fresh poller's paused pace = %s, want 30m", pace)
|
||||
}
|
||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||
t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipPaused)
|
||||
}
|
||||
|
||||
// Past the expiry the same fresh poller runs the Lane normally.
|
||||
fresh.Now = func() time.Time { return now.Add(31 * time.Minute) }
|
||||
fresh.runLanePass(context.Background(), "asura", false)
|
||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != "" {
|
||||
t.Fatalf("pass after the expiry skip = %q, want the loop reached", pass.Skip)
|
||||
}
|
||||
if got := readLatestCheckedAt(t, s, "asura:x"); got == 0 {
|
||||
t.Fatal("the Series was not checked after the pause lifted")
|
||||
}
|
||||
}
|
||||
|
||||
// ResumeLane zeroes the pause and the Lane's next pass finds its full queue
|
||||
// waiting: a pause delays work rather than discarding it, so the due Series
|
||||
// sit unstamped while paused and are all fetched once the pause lifts
|
||||
// (issue #147).
|
||||
func TestResumeLaneRestoresTheQueue(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
for i := 0; i < 3; i++ {
|
||||
seedForCheck(t, s, fmt.Sprintf("asura:s%d", i), "https://asurascans.com/series/x", 0)
|
||||
}
|
||||
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if f.callCount() != 0 {
|
||||
t.Fatalf("fetches while paused = %d, want 0", f.callCount())
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got != 0 {
|
||||
t.Fatalf("asura:s%d stamp while paused = %d, want 0 (due and unstamped)", i, got)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ResumeLane("asura"); err != nil {
|
||||
t.Fatalf("ResumeLane: %v", err)
|
||||
}
|
||||
before := f.callCount()
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := f.callCount() - before; got != 3 {
|
||||
t.Fatalf("fetches after resume = %d, want 3 (the full due queue)", got)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got == 0 {
|
||||
t.Fatalf("asura:s%d still untried after resume", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLanePass prunes in the same call that inserts, so the retention
|
||||
// cutoff the recorder passes is observable in what survives: a row just inside
|
||||
// 14 days behind the poller's clock is kept, one just outside is pruned
|
||||
|
||||
@@ -47,6 +47,8 @@ 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/lanes/{site}/pause", h.adminLanePause},
|
||||
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
||||
{"GET /ui/admin/lanes", h.uiLanes},
|
||||
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
||||
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
@@ -50,6 +51,11 @@ type laneRow struct {
|
||||
// Attention is the one flag the template colours on, so a Lane that
|
||||
// needs the owner is found at a glance rather than read for.
|
||||
Attention bool
|
||||
// Paused is the live pause state — the poll_lanes stamp the pass row
|
||||
// joins on, still in the future — not the pass's skip: the control must
|
||||
// offer Resume from the moment the owner presses Pause, with no pass
|
||||
// having run to record it (issue #147).
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// chip is one named outcome count over the owner's window.
|
||||
@@ -69,6 +75,79 @@ func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
}
|
||||
|
||||
// pauseDurations are the offered pause lengths, by their wire value. A fixed
|
||||
// allow-list rather than time.ParseDuration: the unoffered value must be
|
||||
// refused, and a permissive parser turns the offered set into "anything Go
|
||||
// can read" (issue #147).
|
||||
var pauseDurations = map[string]time.Duration{
|
||||
"1h": time.Hour,
|
||||
"6h": 6 * time.Hour,
|
||||
"24h": 24 * time.Hour,
|
||||
}
|
||||
|
||||
// laneSite reads the Site a lane route names, answering the request itself
|
||||
// when it is not a registry Site. The path value is client-supplied, so it
|
||||
// is checked against the registry before it reaches the store.
|
||||
func laneSite(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
site := r.PathValue("site")
|
||||
if !slices.Contains(latest.SiteNames(), site) {
|
||||
http.Error(w, "unknown site", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
return site, true
|
||||
}
|
||||
|
||||
// adminLanePause writes a bounded pause for one Site and answers with the
|
||||
// freshly rendered Lanes block, so the figures describe the state after the
|
||||
// press. The pause is a fact about the Site — the Lane's next pass reads it
|
||||
// from the durable row, never from this process — so it survives a restart.
|
||||
// The owner gate is the route's, not this handler's; the body is capped like
|
||||
// the API path caps its bodies; the Site and the duration are validated
|
||||
// here, before the store sees them (issue #147).
|
||||
func (h *Handler) adminLanePause(w http.ResponseWriter, r *http.Request) {
|
||||
site, ok := laneSite(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d, ok := pauseDurations[r.PostFormValue("duration")]
|
||||
if !ok {
|
||||
http.Error(w, "unknown pause duration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.PauseLane(site, time.Now().Add(d).UnixMilli()); err != nil {
|
||||
log.Printf("pause lane %s: %v", site, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
}
|
||||
|
||||
// adminLaneResume zeroes one Site's pause and answers with the freshly
|
||||
// rendered Lanes block. Resume is the reversal of a bounded pause, so it
|
||||
// fires instantly with no confirm row (issue #147).
|
||||
func (h *Handler) adminLaneResume(w http.ResponseWriter, r *http.Request) {
|
||||
site, ok := laneSite(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.ResumeLane(site); err != nil {
|
||||
log.Printf("resume lane %s: %v", site, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||
}
|
||||
|
||||
// lanesView builds the Lane status block from the durable pass log. Both
|
||||
// reads are the store's latest-per-Site projection, so the page's seam is a
|
||||
// seeded row rather than a fake poller; errors degrade to the empty state and
|
||||
@@ -147,6 +226,7 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
|
||||
row.Chips = outcomeChips(o)
|
||||
row.HasChips = len(row.Chips) > 0
|
||||
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
|
||||
row.Paused = time.UnixMilli(p.PausedUntil).After(now)
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -179,8 +259,19 @@ func outcomeChips(o store.SiteOutcomes) []chip {
|
||||
// eligible — carry no Attention: the mark must stay spendable on the faults
|
||||
// that actually need the owner.
|
||||
func laneState(p store.LanePass, now time.Time) (phrase string, good, attention bool) {
|
||||
// The pause phrase reads the live poll_lanes stamp the pass row joins
|
||||
// on, not the pass's skip: the owner's press must render as paused on
|
||||
// the very answer it gets, with no pass having run to record it. The
|
||||
// pause is a fact about the Site, and the join delivers it (issue #147).
|
||||
if pausedUntil := time.UnixMilli(p.PausedUntil); pausedUntil.After(now) {
|
||||
phrase = "paused · resumes in " + humanDuration(pausedUntil.Sub(now))
|
||||
good = true
|
||||
return phrase, good, attention
|
||||
}
|
||||
switch p.Skip {
|
||||
case latest.SkipPaused:
|
||||
// A paused pass whose stamp has since lapsed: the Lane still
|
||||
// declined with a reason, so it is never the one true stall.
|
||||
phrase = "paused · resumes in " + humanDuration(time.UnixMilli(p.PausedUntil).Sub(now))
|
||||
good = true
|
||||
case latest.SkipRefusing:
|
||||
|
||||
@@ -32,7 +32,24 @@
|
||||
<span>{{.Gap}}</span>
|
||||
<span>ran {{.Ran}}</span>
|
||||
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}">{{.StatePhrase}}</span>{{end}}</span>
|
||||
<span class="c-ctrl"></span>
|
||||
{{/* The pause control lives in the one slot the design leaves for it:
|
||||
a running Lane offers the three durations and Pause; a paused Lane
|
||||
offers Resume in the same place. Pause is not destruction — it
|
||||
takes nothing away and reverses in one press — so neither wears a
|
||||
confirm row or the danger accent. The form wraps the select so the
|
||||
offered duration travels with the press. */}}
|
||||
<span class="c-ctrl">{{if .Paused}}<span class="pausebar">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML">
|
||||
<button type="submit" class="ghost">Resume</button>
|
||||
</form>
|
||||
</span>{{else}}<span class="pausebar">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML">
|
||||
<select name="duration" aria-label="Pause duration">
|
||||
<option>1h</option><option selected>6h</option><option>24h</option>
|
||||
</select>
|
||||
<button type="submit" class="ghost">Pause</button>
|
||||
</form>
|
||||
</span>{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -763,6 +763,7 @@ func TestAdminRoutesAreOwnerOnly(t *testing.T) {
|
||||
t.Fatalf("route pattern %q has no method", pattern)
|
||||
}
|
||||
path = strings.Replace(path, "{id}", target, 1)
|
||||
path = strings.Replace(path, "{site}", "asura", 1)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -1027,6 +1028,207 @@ func TestBrowserConfigAndReachabilityDerived(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Pressing Pause writes a future expiry at the offered length and answers
|
||||
// with the freshly rendered Lanes block, so the row the owner just pressed
|
||||
// reads as paused with its remaining time and offers Resume — with no poller
|
||||
// having run at all. The pause is a fact about the Site, never a command to
|
||||
// a process (issue #147).
|
||||
func TestLanePauseRoundTrip(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
// A stall-shaped pass — due but none checked, no skip — so the test
|
||||
// proves the pause renders over it as paused, never as the one true
|
||||
// stall.
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000})
|
||||
|
||||
for _, tc := range []struct{ duration, phrase string }{
|
||||
{"1h", "paused · resumes in 1h"},
|
||||
{"6h", "paused · resumes in 6h"},
|
||||
{"24h", "paused · resumes in 24h"},
|
||||
} {
|
||||
t.Run(tc.duration, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause",
|
||||
strings.NewReader("duration="+tc.duration))
|
||||
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 pause status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="lanes"`) {
|
||||
t.Errorf("pause response is not the Lanes block:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, tc.phrase) {
|
||||
t.Errorf("pause response does not render %q:\n%s", tc.phrase, body)
|
||||
}
|
||||
if !strings.Contains(body, `hx-post="/admin/lanes/asura/resume"`) {
|
||||
t.Errorf("pause response does not offer Resume for asura:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="trow attention"`) {
|
||||
t.Errorf("a paused Lane is marked for attention:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "not checking") {
|
||||
t.Errorf("a paused Lane reads as the one true stall:\n%s", body)
|
||||
}
|
||||
// The expiry is a future fact about the Site, at the offered length.
|
||||
paused, _, err := st.LaneGates("asura")
|
||||
if err != nil {
|
||||
t.Fatalf("LaneGates: %v", err)
|
||||
}
|
||||
d, _ := time.ParseDuration(tc.duration)
|
||||
want := time.Now().Add(d).UnixMilli()
|
||||
if paused < want-time.Minute.Milliseconds() || paused > want+time.Minute.Milliseconds() {
|
||||
t.Errorf("pause expiry = %d, want now+%s (%d, within a minute)", paused, tc.duration, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing duration and any value outside the offered set are refused with
|
||||
// 400 and nothing is written: a permissive parser would turn the offered set
|
||||
// into "anything Go can read", and an unoffered pause is a silent outage the
|
||||
// owner left behind (issue #147).
|
||||
func TestLanePauseRejectsBadDurations(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()})
|
||||
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"missing", ""},
|
||||
{"empty value", "duration="},
|
||||
{"unoffered", "duration=2h"},
|
||||
{"zero", "duration=0h"},
|
||||
{"absurd", "duration=999h"},
|
||||
{"not a duration", "duration=six"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause", strings.NewReader(tc.body))
|
||||
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.Errorf("pause body %q status = %d, want 400", tc.body, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
paused, _, err := st.LaneGates("asura")
|
||||
if err != nil {
|
||||
t.Fatalf("LaneGates: %v", err)
|
||||
}
|
||||
if paused != 0 {
|
||||
t.Errorf("a rejected pause still wrote expiry %d", paused)
|
||||
}
|
||||
}
|
||||
|
||||
// The Site in the path is client-supplied, so it is checked against the
|
||||
// registry before the store sees it: an unknown Site is a 400 on both action
|
||||
// routes, not a write (issue #147).
|
||||
func TestLaneActionsRejectUnknownSite(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
for _, tc := range []struct{ path, body string }{
|
||||
{"/admin/lanes/notasite/pause", "duration=6h"},
|
||||
{"/admin/lanes/notasite/resume", ""},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
||||
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.Errorf("POST %s status = %d, want 400", tc.path, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pressing Resume zeroes the pause and answers with the freshly rendered
|
||||
// block: the same slot now offers the duration select and Pause, and the
|
||||
// paused phrase is gone. The queue half of resume lives in the poller tests
|
||||
// (issue #147).
|
||||
func TestLaneResumeRoundTrip(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 2, Checked: 2, GapMS: 10_000})
|
||||
if err := st.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/resume", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("POST resume status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `hx-post="/admin/lanes/asura/pause"`) || !strings.Contains(body, ">Pause</button>") {
|
||||
t.Errorf("resume response does not offer Pause again:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Resume") || strings.Contains(body, "paused") {
|
||||
t.Errorf("resume response still reads as paused:\n%s", body)
|
||||
}
|
||||
paused, _, err := st.LaneGates("asura")
|
||||
if err != nil {
|
||||
t.Fatalf("LaneGates: %v", err)
|
||||
}
|
||||
if paused != 0 {
|
||||
t.Errorf("resume left pause expiry %d, want 0", paused)
|
||||
}
|
||||
}
|
||||
|
||||
// Form bodies on both action routes are capped the way the API path caps
|
||||
// them: an oversized body is a 400, not a memory grant, and nothing is
|
||||
// written (issue #147).
|
||||
func TestLaneActionsCapBody(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()})
|
||||
big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
|
||||
for _, path := range []string{"/admin/lanes/asura/pause", "/admin/lanes/asura/resume"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, 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.Errorf("oversized body on %s status = %d, want 400", path, rr.Code)
|
||||
}
|
||||
}
|
||||
paused, _, err := st.LaneGates("asura")
|
||||
if err != nil {
|
||||
t.Fatalf("LaneGates: %v", err)
|
||||
}
|
||||
if paused != 0 {
|
||||
t.Errorf("an oversized body still paused the Lane (expiry %d)", paused)
|
||||
}
|
||||
}
|
||||
|
||||
// A pause renders as paused, never as stalled: the owner's own act must not
|
||||
// be reported back as a fault. The stamp lives on poll_lanes and the pass
|
||||
// rows join it, so a pause seeded straight into the store — a restart, no
|
||||
// poller anywhere — renders its patina phrase with no attention flag and no
|
||||
// stall mark (issue #147).
|
||||
func TestPausedLaneRendersAsPausedNotStalled(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000})
|
||||
if err := st.PauseLane("asura", now.Add(6*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `class="ok">paused · resumes in 6h<`) {
|
||||
t.Errorf("a paused Lane does not render the patina phrase:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="trow attention"`) {
|
||||
t.Errorf("a paused Lane is marked for attention:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "not checking") {
|
||||
t.Errorf("a paused Lane reads as the one true stall:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="bad"`) {
|
||||
t.Errorf("a paused Lane wears the fault accent:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A Reader past the disagreement threshold is rendered as blocked, and
|
||||
// clearing their marks both zeroes the counters and lifts the block in the
|
||||
// roster the response carries back.
|
||||
|
||||
Reference in New Issue
Block a user