Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions src/lib/anchorsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((_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();
});
Expand All @@ -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 }],
}),
);

Expand All @@ -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",
Expand Down
56 changes: 46 additions & 10 deletions src/lib/metricsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((_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();
});
Expand All @@ -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 () => {
Expand Down
106 changes: 103 additions & 3 deletions src/lib/settlementsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import {
fetchSettlements,
fetchSettlement,
exportSettlementsCsv,
openSettlement,
executeSettlement,
cancelSettlement,
Expand All @@ -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<Response>((_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,
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading