diff --git a/src/lib/anchorsApi.test.ts b/src/lib/anchorsApi.test.ts index 3815d89..5138a2e 100644 --- a/src/lib/anchorsApi.test.ts +++ b/src/lib/anchorsApi.test.ts @@ -16,6 +16,37 @@ function mockFetch(status: number, body: unknown) { }); } +/** + * Builds a `fetch` mock that emulates real fetch's cancellation behaviour: it + * captures the `AbortSignal` handed to the outgoing request and, like the real + * `fetch`, rejects with an `AbortError` `DOMException` when that signal aborts. + * + * `apiRequest` composes the caller's signal with an internal timeout controller + * (see `composeSignals` in `api.ts`), so the object reaching `fetch` is a + * *derived* signal rather than the caller's signal itself. Asserting that an + * abort of the caller's signal propagates to the outgoing request is the + * meaningful, implementation-agnostic way to verify signal forwarding — and it + * is exactly the regression (a request no longer aborting on unmount/reload) + * that this coverage is meant to catch. + */ +function mockAbortableFetch() { + return vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + signal?.addEventListener( + "abort", + () => + reject(new DOMException("The operation was aborted.", "AbortError")), + { once: true }, + ); + }); + }); +} + afterEach(() => { vi.unstubAllGlobals(); }); @@ -25,9 +56,7 @@ describe("anchorsApi", () => { vi.stubGlobal( "fetch", mockFetch(200, { - anchors: [ - { id: "a", name: "A", registeredAt: "", active: true }, - ], + anchors: [{ id: "a", name: "A", registeredAt: "", active: true }], }), ); @@ -50,6 +79,48 @@ describe("anchorsApi", () => { expect(fn.mock.calls[0][0]).toContain("/api/v1/anchors/a"); }); + // `registerAnchor` and `deregisterAnchor` do not accept an AbortSignal, so + // only `fetchAnchors` and `fetchAnchor` need signal-forwarding coverage here. + describe("abort signal forwarding", () => { + it("fetchAnchors forwards the abort signal to the outgoing fetch call", async () => { + const fn = mockAbortableFetch(); + vi.stubGlobal("fetch", fn); + const controller = new AbortController(); + + const promise = fetchAnchors(controller.signal); + + // The request reached `fetch` with an AbortSignal attached. + expect(fn).toHaveBeenCalledTimes(1); + const fetchSignal = fn.mock.calls[0][1].signal as AbortSignal; + expect(fetchSignal).toBeInstanceOf(AbortSignal); + expect(fetchSignal.aborted).toBe(false); + + // Aborting the caller's signal propagates to the outgoing request and + // cancels it with an AbortError. + controller.abort(); + expect(fetchSignal.aborted).toBe(true); + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("fetchAnchor forwards the abort signal to the outgoing fetch call", async () => { + const fn = mockAbortableFetch(); + vi.stubGlobal("fetch", fn); + const controller = new AbortController(); + + const promise = fetchAnchor("a", controller.signal); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0][0]).toContain("/api/v1/anchors/a"); + const fetchSignal = fn.mock.calls[0][1].signal as AbortSignal; + expect(fetchSignal).toBeInstanceOf(AbortSignal); + expect(fetchSignal.aborted).toBe(false); + + controller.abort(); + expect(fetchSignal.aborted).toBe(true); + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + }); + }); + it("surfaces a not-found error for an unknown anchor id", async () => { vi.stubGlobal( "fetch", diff --git a/src/lib/metricsApi.test.ts b/src/lib/metricsApi.test.ts index a83e1ff..ecffdfe 100644 --- a/src/lib/metricsApi.test.ts +++ b/src/lib/metricsApi.test.ts @@ -11,6 +11,38 @@ function mockFetch(status: number, body: unknown) { }); } +/** + * Builds a `fetch` mock that emulates real fetch's cancellation behaviour. + * + * The mock captures the `AbortSignal` handed to the outgoing request and, like + * the real `fetch`, rejects with an `AbortError` `DOMException` when that signal + * is aborted. This lets a test inspect the exact signal that reached `fetch` and + * prove that aborting the caller's signal actually cancels the request. + * + * Note: `apiRequest` composes the caller's signal with an internal timeout + * controller (see `composeSignals` in `api.ts`), so the object reaching `fetch` + * is a *derived* signal rather than the caller's signal itself. Asserting that + * an abort of the caller's signal propagates to the outgoing request is the + * meaningful, implementation-agnostic way to verify signal forwarding. + */ +function mockAbortableFetch() { + return vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + signal?.addEventListener( + "abort", + () => + reject(new DOMException("The operation was aborted.", "AbortError")), + { once: true }, + ); + }); + }); +} + afterEach(() => { vi.unstubAllGlobals(); }); @@ -33,19 +65,23 @@ describe("metricsApi", () => { }); it("passes the abort signal through", async () => { - const fn = mockFetch(200, { - anchors: 0, - activeAnchors: 0, - pools: 0, - totalLiquidity: 0, - settlements: 0, - pendingSettlements: 0, - }); + const fn = mockAbortableFetch(); vi.stubGlobal("fetch", fn); const controller = new AbortController(); - await fetchMetrics(controller.signal); - expect(fn.mock.calls[0][1].signal).toBe(controller.signal); + const promise = fetchMetrics(controller.signal); + + // The request reached `fetch` with an AbortSignal attached. + expect(fn).toHaveBeenCalledTimes(1); + const fetchSignal = fn.mock.calls[0][1].signal as AbortSignal; + expect(fetchSignal).toBeInstanceOf(AbortSignal); + expect(fetchSignal.aborted).toBe(false); + + // Aborting the caller's signal propagates to the outgoing request's signal + // and cancels the in-flight request with an AbortError. + controller.abort(); + expect(fetchSignal.aborted).toBe(true); + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); }); it("propagates API error status, code, and message", async () => { diff --git a/src/lib/settlementsApi.test.ts b/src/lib/settlementsApi.test.ts index dcd9947..fedb418 100644 --- a/src/lib/settlementsApi.test.ts +++ b/src/lib/settlementsApi.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { fetchSettlements, fetchSettlement, + exportSettlementsCsv, openSettlement, executeSettlement, cancelSettlement, @@ -18,6 +19,37 @@ function mockFetch(status: number, body: unknown) { return fn; } +/** + * Builds a `fetch` mock that emulates real fetch's cancellation behaviour: it + * captures the `AbortSignal` handed to the outgoing request and, like the real + * `fetch`, rejects with an `AbortError` `DOMException` when that signal aborts. + * + * `apiRequest`/`apiTextRequest` compose the caller's signal with an internal + * timeout controller (see `composeSignals` in `api.ts`), so the object reaching + * `fetch` is a *derived* signal rather than the caller's signal itself. + * Asserting that an abort of the caller's signal propagates to the outgoing + * request is the meaningful, implementation-agnostic way to verify signal + * forwarding — exactly the regression (a request no longer aborting on + * unmount/reload) that this coverage is meant to catch. + */ +function mockAbortableFetch() { + return vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + signal?.addEventListener( + "abort", + () => + reject(new DOMException("The operation was aborted.", "AbortError")), + { once: true }, + ); + }); + }); +} + function settlement(status = "pending") { return { id: 1, @@ -101,8 +133,12 @@ describe("settlementsApi", () => { vi.stubGlobal("fetch", fn); const { exportSettlementsCsv } = await import("./settlementsApi"); - const result = await exportSettlementsCsv({ anchor: "b", page: 1, pageSize: 50 }); - + const result = await exportSettlementsCsv({ + anchor: "b", + page: 1, + pageSize: 50, + }); + expect(result).toBe("id,anchor\n1,a"); const url = fn.mock.calls[0][0] as string; expect(url).toContain("anchor=b"); @@ -112,6 +148,68 @@ describe("settlementsApi", () => { }); }); + describe("abort signal forwarding", () => { + it("fetchSettlements forwards the abort signal to the outgoing fetch call", async () => { + const fn = mockAbortableFetch(); + vi.stubGlobal("fetch", fn); + const controller = new AbortController(); + + const promise = fetchSettlements({ + anchor: "a", + signal: controller.signal, + }); + + // The request reached `fetch` with an AbortSignal attached. + expect(fn).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0][0]).toContain("anchor=a"); + const fetchSignal = fn.mock.calls[0][1].signal as AbortSignal; + expect(fetchSignal).toBeInstanceOf(AbortSignal); + expect(fetchSignal.aborted).toBe(false); + + // Aborting the caller's signal propagates to the outgoing request and + // cancels it with an AbortError. + controller.abort(); + expect(fetchSignal.aborted).toBe(true); + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("fetchSettlement forwards the abort signal to the outgoing fetch call", async () => { + const fn = mockAbortableFetch(); + vi.stubGlobal("fetch", fn); + const controller = new AbortController(); + + const promise = fetchSettlement(1, controller.signal); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0][0]).toContain("/api/v1/settlements/1"); + const fetchSignal = fn.mock.calls[0][1].signal as AbortSignal; + expect(fetchSignal).toBeInstanceOf(AbortSignal); + expect(fetchSignal.aborted).toBe(false); + + controller.abort(); + expect(fetchSignal.aborted).toBe(true); + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("exportSettlementsCsv forwards the abort signal to the outgoing fetch call", async () => { + const fn = mockAbortableFetch(); + vi.stubGlobal("fetch", fn); + const controller = new AbortController(); + + const promise = exportSettlementsCsv({ signal: controller.signal }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0][0]).toContain("format=csv"); + const fetchSignal = fn.mock.calls[0][1].signal as AbortSignal; + expect(fetchSignal).toBeInstanceOf(AbortSignal); + expect(fetchSignal.aborted).toBe(false); + + controller.abort(); + expect(fetchSignal.aborted).toBe(true); + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + }); + }); + it("fetches a single settlement by id", async () => { const fn = mockFetch(200, settlement()); vi.stubGlobal("fetch", fn); @@ -124,7 +222,9 @@ describe("settlementsApi", () => { it("surfaces a not-found error for an unknown settlement id", async () => { vi.stubGlobal( "fetch", - mockFetch(404, { error: { code: "NOT_FOUND", message: "no settlement" } }), + mockFetch(404, { + error: { code: "NOT_FOUND", message: "no settlement" }, + }), ); await expect(fetchSettlement(999)).rejects.toBeInstanceOf(ApiRequestError);