gendesign/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts
Light1YT 16e505326d
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been cancelled
perf(site-finder): honour AbortSignal в poll-цикле useParcelAnalyzeQuery (#1248)
queryFn игнорировал TanStack v5 AbortSignal: first POST /analyze, poll GET
/fetch-status и re-POST не передавали signal, цикл спал на non-abortable
setTimeout без проверки signal.aborted. При unmount / смене cad|horizon цикл
жил до 2-мин кэпа, дёргая GET /fetch-status каждые 2с + POST /analyze на каждый
ready — зомби-поллинг. Проброс signal во все 3 fetch, sleep через shared
abortableSleep (extracted из useSiteAnalysis #1242), throw AbortError перед
итерацией. +3 vitest (abort + happy-path).

Closes #1248
2026-06-13 13:19:50 +00:00

247 lines
8.5 KiB
TypeScript

/**
* 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<unknown>;
}
// 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<unknown> {
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);
});
});