/** * Regression coverage for issue #1248 (P3 performance). * * Bug: `useParcelAnalyzeQuery`'s queryFn polled GET /fetch-status (+ re-POST * /analyze) in a `for (i < 60)` loop with a non-abortable `setTimeout` and never * read the TanStack Query v5 AbortSignal. On unmount / смена cad|horizon the * loop kept firing requests up to the 2-min hard cap — zombie-polling. * * Fix: queryFn destructures `{ signal }`, forwards it to every apiFetch call, * sleeps via `abortableSleep`, and throws AbortError before each iteration once * `signal.aborted`. The sibling mutation flow (useSiteAnalysis #1242) already * did this; both now share `@/lib/abortableSleep`. * * Strategy: `@tanstack/react-query` is mocked so the hook's `useQuery(options)` * call simply captures the options object. We then invoke `options.queryFn` * directly with a real AbortSignal and a per-URL `fetch` stub, under fake * timers, and assert on abort behaviour + the happy path. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Capture the options passed to useQuery ─────────────────────────────────── // The captured options carry the polling queryFn we want to drive directly. interface CapturedQueryOptions { queryKey: readonly unknown[]; queryFn: (ctx: { signal: AbortSignal }) => Promise; } // vi.mock is hoisted; keep the captured ref in a hoisted holder so both the // factory and the tests can reach it. const captured = vi.hoisted<{ options: CapturedQueryOptions | null }>(() => ({ options: null, })); vi.mock("@tanstack/react-query", () => ({ keepPreviousData: Symbol("keepPreviousData"), useQuery: (options: CapturedQueryOptions) => { captured.options = options; return {}; }, })); import { useParcelAnalyzeQuery } from "../site-finder-api"; // ── fetch router ───────────────────────────────────────────────────────────── interface MockSpec { status: number; body?: unknown; } /** Minimal Response stub — only the fields apiFetch/apiFetchWithStatus read. */ function makeResponse({ status, body }: MockSpec): Response { const text = body == null ? "" : JSON.stringify(body); return { ok: status >= 200 && status < 300, status, text: () => Promise.resolve(text), } as unknown as Response; } /** * Routes the stubbed fetch by URL + method and records every call so we can * assert that nothing fires after abort. `statusSeq` is the queue of * /fetch-status responses, consumed in order. */ function installFetchRouter(opts: { firstAnalyze: MockSpec; statusSeq: MockSpec[]; secondAnalyze?: MockSpec; }) { const calls: Array<{ url: string; method: string }> = []; let analyzeCount = 0; let statusIdx = 0; const fn = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); calls.push({ url, method }); // Mirror the browser: a pre-aborted signal makes fetch reject immediately. if (init?.signal?.aborted) { return Promise.reject(new DOMException("Aborted", "AbortError")); } if (url.includes("/analyze")) { analyzeCount += 1; const spec = analyzeCount === 1 ? opts.firstAnalyze : (opts.secondAnalyze ?? { status: 200, body: {} }); return Promise.resolve(makeResponse(spec)); } if (url.includes("/fetch-status")) { const spec = opts.statusSeq[statusIdx] ?? { status: 200, body: { status: "fetching" }, }; statusIdx += 1; return Promise.resolve(makeResponse(spec)); } return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fn); return { calls, statusCalls: () => calls.filter((c) => c.url.includes("/fetch-status")).length, analyzeCalls: () => calls.filter((c) => c.url.includes("/analyze")).length, }; } const CAD = "66:41:0701045:42"; /** * Run the hook so the mocked `useQuery` captures its options, then return the * polling queryFn. Reads `captured.options` via a fresh binding so TS control- * flow doesn't pin it (the hook mutates it opaquely through the mock). * * `useQuery` is fully mocked (it just records its options, no React state), so * the rules-of-hooks invariant does not apply to this call — disable locally. */ function getQueryFn(): CapturedQueryOptions["queryFn"] { // eslint-disable-next-line react-hooks/rules-of-hooks useParcelAnalyzeQuery(CAD, 12); const options = captured.options; if (options === null) throw new Error("useQuery options not captured"); return options.queryFn; } function runQueryFn(signal: AbortSignal): Promise { return getQueryFn()({ signal }); } describe("useParcelAnalyzeQuery — poll loop honours AbortSignal (#1248)", () => { beforeEach(() => { captured.options = null; vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("aborts the poll loop and stops issuing requests after abort", async () => { const controller = new AbortController(); const router = installFetchRouter({ firstAnalyze: { status: 202, body: { status: "fetching", job_id: 1, eta_seconds: 30 }, }, // Always "fetching" → the loop would otherwise run to the 60-iter cap. statusSeq: Array.from({ length: 60 }, () => ({ status: 200, body: { status: "fetching", job_id: 1, error_msg: null, eta_seconds: 10, }, })), }); const promise = runQueryFn(controller.signal).catch((e: unknown) => e); // First POST /analyze (202) resolves and the loop enters its first sleep. await vi.advanceTimersByTimeAsync(0); expect(router.analyzeCalls()).toBe(1); // One poll cycle: sleep 2s → GET /fetch-status ("fetching"). await vi.advanceTimersByTimeAsync(2000); expect(router.statusCalls()).toBeGreaterThanOrEqual(1); // Abort mid-flight (как unmount / смена cad). abortableSleep wakes at once. controller.abort(); await vi.advanceTimersByTimeAsync(0); const result = await promise; expect(result).toBeInstanceOf(DOMException); expect((result as DOMException).name).toBe("AbortError"); // No further requests after abort — even if the full 2-min cap elapses. const callsAtAbort = router.calls.length; await vi.advanceTimersByTimeAsync(60 * 2000); expect(router.calls.length).toBe(callsAtAbort); }); it("does not poll at all when the signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); const router = installFetchRouter({ firstAnalyze: { status: 202, body: { status: "fetching", job_id: 1, eta_seconds: 30 }, }, statusSeq: [], }); const result = await runQueryFn(controller.signal).catch((e: unknown) => e); expect(result).toBeInstanceOf(DOMException); expect((result as DOMException).name).toBe("AbortError"); // First POST /analyze sees the aborted signal → rejects, never polls. expect(router.statusCalls()).toBe(0); }); it("happy path: polls until ready then resolves with the 200 analysis", async () => { const controller = new AbortController(); const payload = { cad_num: CAD, score: 72, source: "nspd" }; const router = installFetchRouter({ firstAnalyze: { status: 202, body: { status: "fetching", job_id: 1, eta_seconds: 30 }, }, statusSeq: [ { status: 200, body: { status: "fetching", job_id: 1, error_msg: null, eta_seconds: 10, }, }, { status: 200, body: { status: "ready", job_id: 1, error_msg: null, eta_seconds: 0 }, }, ], secondAnalyze: { status: 200, body: payload }, }); const promise = runQueryFn(controller.signal); // first POST 202 → sleep → status(fetching) → sleep → status(ready) → re-POST 200 await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(0); await expect(promise).resolves.toEqual(payload); expect(router.analyzeCalls()).toBe(2); // first + re-trigger expect(router.statusCalls()).toBe(2); }); });