diff --git a/backend/internal/latest/acquire_test.go b/backend/internal/latest/acquire_test.go index 9ff6def..4a6cd0c 100644 --- a/backend/internal/latest/acquire_test.go +++ b/backend/internal/latest/acquire_test.go @@ -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) { diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index 70da921..bf8d7c8 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -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 diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 0a34c7a..ca39e8c 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -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}, diff --git a/backend/internal/web/admin_lanes.go b/backend/internal/web/admin_lanes.go index 7cc5bd1..599bb64 100644 --- a/backend/internal/web/admin_lanes.go +++ b/backend/internal/web/admin_lanes.go @@ -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: diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html index 5ac35d4..c4a359e 100644 --- a/backend/internal/web/templates/lanes.html +++ b/backend/internal/web/templates/lanes.html @@ -32,7 +32,24 @@ {{.Gap}} ran {{.Ran}} {{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}} · {{end}}{{$c.Name}} {{$c.Count}}{{end}}{{else}}none observed{{end}}{{if .StatePhrase}} · {{.StatePhrase}}{{end}} - + {{/* 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. */}} + {{if .Paused}} +
+ +
+
{{else}} +
+ + +
+
{{end}}
{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index 363d293..3d701f5 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -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") { + 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.