feat(web): read admin lanes from the durable pass log, not a poller snapshot (#145)
The Lanes page now projects store.LatestLanePasses and the owner-window outcomes (store.LanePassOutcomes) into per-Site rows instead of reading an in-memory Poller snapshot, so a deploy answers the instant the store is up. Browser configuration is a config fact and reachability is derived from recent browser-Site passes inside latest.RefuseBackoff. This atomically deletes the in-memory path in the same commit that makes the page read the DB: latest/status.go (LaneState, Status, LaneStatus, recordLaneState) and the web.LaneReporter seam plus fakeLanes are gone, and latest.refuseBackoff is renamed latest.RefuseBackoff at every callsite. ownerWindow (#142) is referenced, never declared (contract C2)
This commit is contained in:
+216
-86
@@ -28,17 +28,13 @@ import (
|
||||
const testOwnerID = "owner-snowflake"
|
||||
|
||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||
// can seed rows and assert on what the handlers wrote back. An optional lane
|
||||
// reporter stands in for the running poller; omitted means none is running,
|
||||
// which is what every test that is not about the admin page wants.
|
||||
func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
|
||||
// can seed rows and assert on what the handlers wrote back. The Lanes page
|
||||
// reads the pass log (issue #145), so a test seeds store rows rather than
|
||||
// standing in for a poller.
|
||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
var reporter web.LaneReporter
|
||||
if len(lanes) > 0 {
|
||||
reporter = lanes[0]
|
||||
}
|
||||
return newRouter(st, cfg, reporter), st
|
||||
return newRouter(st, cfg), st
|
||||
}
|
||||
|
||||
// sessionCookie mints a live session row for the owner and returns the cookie
|
||||
@@ -149,12 +145,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
|
||||
|
||||
// oauthWebTestServer returns the full router, its store, and a Discord stub
|
||||
// wired as the configured API — the starting point for sign-in tests.
|
||||
func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
|
||||
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
|
||||
t.Helper()
|
||||
stub, srv := newDiscordStub(t)
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
router, st := newWebTestServer(t, cfg, lanes...)
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
return router, st, stub
|
||||
}
|
||||
|
||||
@@ -418,7 +414,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
cfg.Discord.RequiredRole = tc.require
|
||||
st := newTestStore(t)
|
||||
router := newRouter(st, cfg, nil)
|
||||
router := newRouter(st, cfg)
|
||||
|
||||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||||
if rr.Code != http.StatusForbidden {
|
||||
@@ -634,11 +630,37 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
|
||||
// page's tests need neither a poller nor a Site.
|
||||
type fakeLanes struct{ status latest.Status }
|
||||
// seedPass writes one durable pass row — the Lanes page's whole seam.
|
||||
// The page renders from the database with no poller running at all (issue
|
||||
// #145), so a test seeds rows instead of constructing a fake reporter.
|
||||
func seedPass(t *testing.T, st *store.Store, p store.LanePass) {
|
||||
t.Helper()
|
||||
if err := st.RecordLanePass(p, -1); err != nil {
|
||||
t.Fatalf("seed pass %s: %v", p.Site, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f fakeLanes) LaneStatus() latest.Status { return f.status }
|
||||
// lanesConfig returns a config with latest-chapter polling switched on, so
|
||||
// the Lanes page's statusline speaks about the browser rather than about
|
||||
// polling being off.
|
||||
func lanesConfig() Config {
|
||||
cfg := testConfig()
|
||||
cfg.LatestPoll.Enabled = true
|
||||
return cfg
|
||||
}
|
||||
|
||||
// lanesBody fetches the Lanes fragment as the owner and returns the body.
|
||||
func lanesBody(t *testing.T, router http.Handler, st *store.Store) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
// Every admin address carries the same navigation, while the roster only lives
|
||||
// on its own page and the other pages keep their shells independent.
|
||||
@@ -769,32 +791,22 @@ func TestAdminRoutesAreOwnerOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Lane block reports what the poller says, and marks the Lanes that need
|
||||
// attention — a clamped gap, a refusal, a Site whose pages can only be read
|
||||
// through a sidecar that is not there, and a Lane with Series waiting that its
|
||||
// last pass did not read.
|
||||
// The Lane block reports what the pass log says, and marks the Lanes that
|
||||
// need attention: a Site whose pages can only be read through a sidecar that
|
||||
// is not there, and a Lane with Series waiting that its last pass did not
|
||||
// read. Rows come from seeded database rows — no poller runs anywhere.
|
||||
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
|
||||
{Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
|
||||
{Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
|
||||
{Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
|
||||
},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-90 * time.Second).UnixMilli(), Due: 12, Checked: 12, GapMS: 40_000})
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 3, Checked: 3, GapMS: 60_000})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 400, Checked: 400, GapMS: 8_000})
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 7, Checked: 0, GapMS: 60_000})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} {
|
||||
body := lanesBody(t, router, st)
|
||||
for _, want := range []string{"asura", "kagane", "demonic", "comix", "40s", "ran 1m30s ago", "not checking", "none observed", "reachable"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane status lacks %q:\n%s", want, body)
|
||||
}
|
||||
@@ -810,78 +822,75 @@ func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
|
||||
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
|
||||
// Series due with none checked is the design working, not a stopped Lane. The
|
||||
// two must not render the same mark: "not checking" is the owner's cue to go
|
||||
// looking, and spending it on the commonest healthy browser-Lane state trains
|
||||
// them to ignore it.
|
||||
// pass row records it with the asleep skip, and the page must render its
|
||||
// sleeping sentence with no stall mark and no attention.
|
||||
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true},
|
||||
},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{
|
||||
Site: "kagane", RanAt: time.Now().UnixMilli(),
|
||||
Skip: latest.SkipAsleep, Due: 1, Checked: 0, GapMS: 10_000,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
body := lanesBody(t, router, st)
|
||||
if strings.Contains(body, "not checking") {
|
||||
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "browser asleep") {
|
||||
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="attention"`) {
|
||||
if strings.Contains(body, `class="trow attention"`) {
|
||||
t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A Lane whose pass never reached a figure must not have that figure drawn as
|
||||
// a zero: a refusing Lane still reports the due count and gap its last real
|
||||
// pass saw, and a Lane that has never reached one omits it entirely.
|
||||
// a zero: a refusing Lane that has never gathered figures draws "—" for the
|
||||
// gap rather than stating a zero it did not measure.
|
||||
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
st, dsn := newTestStoreURL(t)
|
||||
router := newRouter(st, cfg)
|
||||
now := time.Now()
|
||||
oldNow := now.Add(-time.Minute).UnixMilli()
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: oldNow, Skip: latest.SkipRefusing})
|
||||
// Refusal expiry lives on the Lane (poll_lanes), not the pass row, so it
|
||||
// must be seeded there for the backs-off-until clause to render.
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`INSERT INTO poll_lanes (site, refuse_until) VALUES ($1, $2)`, "comix", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed lane refusal: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "gap 0s") {
|
||||
body := lanesBody(t, router, st)
|
||||
if strings.Contains(body, `>0s<`) {
|
||||
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "refusing") {
|
||||
t.Errorf("a refusing Lane is not marked as such:\n%s", body)
|
||||
if !strings.Contains(body, "refusing · backs off until") {
|
||||
t.Errorf("a refusing Lane is not marked with its backoff time:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// No poller and a poller that has not finished a pass both render "no data
|
||||
// yet" rather than zeroes that read as a stopped backend — but they are not
|
||||
// the same fact, so the page must not blame the sidecar when nothing polls.
|
||||
// Polling switched off and a browser not configured are different facts, and
|
||||
// the page must not blame the sidecar when nothing polls. Neither is a poller
|
||||
// running — the Lanes page answers entirely from config and the pass log.
|
||||
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
lanes []web.LaneReporter
|
||||
cfg Config
|
||||
want, unwant string
|
||||
}{
|
||||
{"no poller", nil, "Polling is switched off", "not configured"},
|
||||
{"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
|
||||
{"polling switched off", testConfig(), "Polling is switched off", "not configured"},
|
||||
{"browser not configured", lanesConfig(), "not configured", "Polling is switched off"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t, tc.lanes...)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
router, st := newWebTestServer(t, tc.cfg)
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "No data yet") {
|
||||
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
|
||||
}
|
||||
@@ -895,12 +904,133 @@ func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restart survival is the point of the durable lane state: rows seeded into
|
||||
// poll_passes render with no poller running anywhere, complete thirty seconds
|
||||
// after a deploy. This is the test that proves the in-memory path is gone.
|
||||
func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
// A pass a minute ago, another three hours ago: the latest per Site wins.
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1, Checked: 1, GapMS: 10_000})
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `class="c-site">asura</span>`) {
|
||||
t.Fatalf("asura row missing from a restart-read database:\n%s", body)
|
||||
}
|
||||
// The fragment carries its own single timer and answers the swap it asked
|
||||
// for, so the page keeps refreshing against the database.
|
||||
if !strings.Contains(body, `hx-get="/ui/admin/lanes"`) || !strings.Contains(body, `hx-trigger="every 30s"`) {
|
||||
t.Errorf("Lanes fragment lost its self-refresh:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A skip reason is the whole difference between a Lane resting and a Lane
|
||||
// stuck: a skipped pass says why it declined, and the one true stall — empty
|
||||
// skip with Series due and none read — is the only thing that draws the fault.
|
||||
func TestSkipReasonIsNotAStall(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
// Asleep: due but none checked, with a skip that says why — no stall, no
|
||||
// attention.
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 2, Checked: 0, GapMS: 10_000})
|
||||
// The true stall: reached the loop, Series waiting, none read.
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 20_000})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "browser asleep") {
|
||||
t.Errorf("the asleep row does not say why it declined:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "not checking") {
|
||||
t.Errorf("the true stall is not rendered as a fault:\n%s", body)
|
||||
}
|
||||
// Exactly the stall row wears attention; the asleep row never does.
|
||||
if strings.Count(body, `class="trow attention"`) != 1 {
|
||||
t.Errorf("attention is on %d rows, want exactly the stall:\n%s", strings.Count(body, `class="trow attention"`), body)
|
||||
}
|
||||
}
|
||||
|
||||
// The five outcome counts render named — the page never prints the word
|
||||
// "failures" — and a Site with none observed says so rather than drawing a
|
||||
// blank cell.
|
||||
func TestNamedOutcomeChips(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, NoChapter: 2, Errors: 1})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 3, Unfetchable: 4})
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli()})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
for _, want := range []string{"refused 5", "no chapter 2", "errors 1", "unreachable 3", "unfetchable 4"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane chips lack %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "failures") {
|
||||
t.Errorf("the page prints the forbidden word \"failures\":\n%s", body)
|
||||
}
|
||||
// comix has no outcomes in the window: it must say none observed, and the
|
||||
// phrase must not be blank.
|
||||
if !strings.Contains(body, "none observed") {
|
||||
t.Errorf("a Site with no outcomes does not say none observed:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Browser configuration is a deployment fact and reachability is derived from
|
||||
// the latest browser-Site passes inside the refusal backoff — no poller in any
|
||||
// of the three.
|
||||
func TestBrowserConfigAndReachabilityDerived(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
t.Run("not configured", func(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipNoFetcher})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "not configured") || strings.Contains(body, "unreachable") {
|
||||
t.Errorf("unset BROWSER_WS_URL must read as not configured:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unreachable from recent sidecar-down", func(t *testing.T) {
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "unreachable") {
|
||||
t.Errorf("recent sidecar-down passes must read as unreachable:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reachable from clean passes", func(t *testing.T) {
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Checked: 3})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "reachable") {
|
||||
t.Errorf("clean browser passes must read as reachable:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sidecar-down outside the backoff is reachable again", func(t *testing.T) {
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-30 * time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "reachable") {
|
||||
t.Errorf("a sidecar-down older than the backoff must read as reachable:\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.
|
||||
func TestOwnerClearsReaderMarks(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
router := newRouter(st, testConfig(), nil)
|
||||
router := newRouter(st, testConfig())
|
||||
cookie := sessionCookie(t, st)
|
||||
// The counters are filled by issue #103; until it lands the only way to
|
||||
// stand a marked Reader up is to write the columns directly.
|
||||
|
||||
Reference in New Issue
Block a user