|
| 1 | +/** |
| 2 | + * REAL-SDK regression test for the PocketBase auto-cancellation bug. |
| 3 | + * |
| 4 | + * The sibling `useLiveStatus.test.tsx` mocks `../lib/pb`, so it never |
| 5 | + * exercises the real PocketBase JS SDK and therefore MISSED this bug: the |
| 6 | + * SDK derives a request key from `method + path` and AUTO-CANCELS any |
| 7 | + * in-flight request that shares it. `fetchInitial` fans pages 2..N out |
| 8 | + * CONCURRENTLY at the SAME path (`/api/collections/status/records`), so |
| 9 | + * every page after the first would cancel its predecessor — the cancelled |
| 10 | + * promises reject, `Promise.all` rejects, and the hook drops to OFFLINE. |
| 11 | + * |
| 12 | + * This test stands up a real in-process Node http server that serves the |
| 13 | + * status records endpoint paged with a per-page delay (so multiple fan-out |
| 14 | + * pages are genuinely in flight at once), points the PRODUCTION `getPb()` |
| 15 | + * client at it, drives the REAL hook, and asserts it reaches "live" with |
| 16 | + * ALL pages' rows. It is RED if `requestKey: null` is removed from the list |
| 17 | + * options and GREEN with it. |
| 18 | + * |
| 19 | + * NOTE: this file deliberately does NOT mock `../lib/pb` — it uses the real |
| 20 | + * SDK against a real socket. EventSource + localStorage are stubbed because |
| 21 | + * jsdom lacks the realtime/SSE plumbing the subscribe() path needs; we only |
| 22 | + * assert the initial paged fetch here (the bug lives in fetchInitial, not in |
| 23 | + * the SSE subscribe path). |
| 24 | + */ |
| 25 | +import { |
| 26 | + describe, |
| 27 | + it, |
| 28 | + expect, |
| 29 | + beforeAll, |
| 30 | + afterAll, |
| 31 | + beforeEach, |
| 32 | + afterEach, |
| 33 | + vi, |
| 34 | +} from "vitest"; |
| 35 | +import { renderHook, waitFor } from "@testing-library/react"; |
| 36 | +import { createServer } from "node:http"; |
| 37 | +import type { Server } from "node:http"; |
| 38 | +import type { AddressInfo } from "node:net"; |
| 39 | + |
| 40 | +// Total rows the fake server serves, spread across PB's 500-row page clamp. |
| 41 | +// 1300 rows → 3 pages (500 + 500 + 300): page 1 reports totalPages, then the |
| 42 | +// hook fans out pages 2 & 3 concurrently — exactly the scenario the SDK's |
| 43 | +// auto-cancellation breaks. |
| 44 | +const TOTAL_ROWS = 1300; |
| 45 | +const PER_PAGE_CLAMP = 500; |
| 46 | +// Per-page artificial latency. Long enough that pages 2 & 3 are GENUINELY |
| 47 | +// in flight simultaneously, so the SDK's same-path auto-cancel actually |
| 48 | +// fires (a zero-delay server might resolve page 2 before page 3 is even |
| 49 | +// dispatched, masking the bug). |
| 50 | +const PAGE_DELAY_MS = 40; |
| 51 | + |
| 52 | +function makeRow(i: number): Record<string, unknown> { |
| 53 | + const id = `r${String(i).padStart(4, "0")}`; |
| 54 | + return { |
| 55 | + id, |
| 56 | + key: `smoke:int/f${id}`, |
| 57 | + dimension: "smoke", |
| 58 | + state: "green", |
| 59 | + signal: {}, |
| 60 | + observed_at: "2026-04-20T00:00:00Z", |
| 61 | + transitioned_at: "2026-04-20T00:00:00Z", |
| 62 | + fail_count: 0, |
| 63 | + first_failure_at: null, |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +const ALL_ROWS = Array.from({ length: TOTAL_ROWS }, (_, i) => makeRow(i)); |
| 68 | + |
| 69 | +/** |
| 70 | + * Minimal PocketBase-compatible list endpoint. Honours `?page=`/`?perPage=` |
| 71 | + * (clamped to 500, like real PB) and delays each response by PAGE_DELAY_MS so |
| 72 | + * concurrent fan-out pages overlap on the wire. |
| 73 | + */ |
| 74 | +function startPbServer(): Promise<{ server: Server; url: string }> { |
| 75 | + const server = createServer((req, res) => { |
| 76 | + const url = new URL(req.url ?? "/", "http://127.0.0.1"); |
| 77 | + if (!url.pathname.startsWith("/api/collections/status/records")) { |
| 78 | + res.statusCode = 404; |
| 79 | + res.end(JSON.stringify({ message: "not found" })); |
| 80 | + return; |
| 81 | + } |
| 82 | + const page = Number(url.searchParams.get("page") ?? "1"); |
| 83 | + const perPage = Math.min( |
| 84 | + Number(url.searchParams.get("perPage") ?? String(PER_PAGE_CLAMP)), |
| 85 | + PER_PAGE_CLAMP, |
| 86 | + ); |
| 87 | + const start = (page - 1) * perPage; |
| 88 | + const items = ALL_ROWS.slice(start, start + perPage); |
| 89 | + const body = JSON.stringify({ |
| 90 | + page, |
| 91 | + perPage, |
| 92 | + totalItems: TOTAL_ROWS, |
| 93 | + totalPages: Math.max(1, Math.ceil(TOTAL_ROWS / perPage)), |
| 94 | + items, |
| 95 | + }); |
| 96 | + // Delay so concurrent fan-out pages are genuinely in flight together. |
| 97 | + setTimeout(() => { |
| 98 | + res.setHeader("content-type", "application/json"); |
| 99 | + res.end(body); |
| 100 | + }, PAGE_DELAY_MS); |
| 101 | + }); |
| 102 | + return new Promise((resolve) => { |
| 103 | + // Bind to 127.0.0.1 (not localhost) — the PB SDK warns localhost can |
| 104 | + // mis-resolve to ::1 and refuse the connection in Node. |
| 105 | + server.listen(0, "127.0.0.1", () => { |
| 106 | + const { port } = server.address() as AddressInfo; |
| 107 | + resolve({ server, url: `http://127.0.0.1:${port}` }); |
| 108 | + }); |
| 109 | + }); |
| 110 | +} |
| 111 | + |
| 112 | +let server: Server; |
| 113 | +let baseUrl: string; |
| 114 | + |
| 115 | +beforeAll(async () => { |
| 116 | + const started = await startPbServer(); |
| 117 | + server = started.server; |
| 118 | + baseUrl = started.url; |
| 119 | +}); |
| 120 | + |
| 121 | +afterAll(() => { |
| 122 | + server.close(); |
| 123 | +}); |
| 124 | + |
| 125 | +beforeEach(() => { |
| 126 | + // jsdom has neither EventSource (PB realtime/SSE) nor a usable localStorage |
| 127 | + // for the SDK's auth store. Stub both so constructing/driving the real |
| 128 | + // PocketBase client doesn't blow up — we never exercise the SSE subscribe |
| 129 | + // path here (the bug under test is in the initial paged fetch). |
| 130 | + (globalThis as unknown as { EventSource: unknown }).EventSource = class { |
| 131 | + close(): void {} |
| 132 | + addEventListener(): void {} |
| 133 | + removeEventListener(): void {} |
| 134 | + }; |
| 135 | + const store = new Map<string, string>(); |
| 136 | + Object.defineProperty(globalThis, "localStorage", { |
| 137 | + configurable: true, |
| 138 | + value: { |
| 139 | + getItem: (k: string) => store.get(k) ?? null, |
| 140 | + setItem: (k: string, v: string) => store.set(k, v), |
| 141 | + removeItem: (k: string) => store.delete(k), |
| 142 | + clear: () => store.clear(), |
| 143 | + }, |
| 144 | + }); |
| 145 | + // Point the production runtime-config reader at our fake server, then reset |
| 146 | + // the pb module so getPb() rebuilds its singleton against this URL. |
| 147 | + ( |
| 148 | + globalThis as unknown as { window: Window & typeof globalThis } |
| 149 | + ).window.__SHOWCASE_CONFIG__ = { |
| 150 | + pocketbaseUrl: baseUrl, |
| 151 | + shellUrl: baseUrl, |
| 152 | + opsBaseUrl: baseUrl, |
| 153 | + }; |
| 154 | + vi.resetModules(); |
| 155 | +}); |
| 156 | + |
| 157 | +afterEach(() => { |
| 158 | + vi.resetModules(); |
| 159 | +}); |
| 160 | + |
| 161 | +describe("useLiveStatus (real PocketBase SDK — auto-cancellation regression)", () => { |
| 162 | + it("reaches live with ALL pages despite concurrent same-path fan-out", async () => { |
| 163 | + // Import AFTER resetModules + config injection so the hook closes over a |
| 164 | + // freshly-constructed pb singleton pointed at our fake server. |
| 165 | + const { useLiveStatus } = await import("./useLiveStatus"); |
| 166 | + const { result } = renderHook(() => useLiveStatus("smoke")); |
| 167 | + |
| 168 | + // Without `requestKey: null`, pages 2 & 3 share the page-1 auto request |
| 169 | + // key, get auto-cancelled, Promise.all rejects, and the hook lands in |
| 170 | + // "error" (or never reaches "live") instead. The fix lets every page |
| 171 | + // complete → all 1300 rows → "live". |
| 172 | + await waitFor(() => expect(result.current.status).toBe("live"), { |
| 173 | + timeout: 5000, |
| 174 | + }); |
| 175 | + expect(result.current.rows).toHaveLength(TOTAL_ROWS); |
| 176 | + expect(result.current.error).toBeNull(); |
| 177 | + }, 10000); |
| 178 | +}); |
0 commit comments