245 lines
7.4 KiB
JavaScript
245 lines
7.4 KiB
JavaScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createHttpClient } from "../../src/adapters/http/client.js";
|
|
import { queryKeys } from "../../src/contracts/query-keys.js";
|
|
|
|
/** @param {unknown} data */
|
|
function successResponse(data) {
|
|
return Response.json({
|
|
success: true,
|
|
data,
|
|
meta: { requestId: "request-1", traceId: "trace-1" },
|
|
});
|
|
}
|
|
|
|
/** @param {number} status */
|
|
function failureResponse(status) {
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: { code: "TEMPORARY" },
|
|
meta: { requestId: "request-1", traceId: "trace-1" },
|
|
},
|
|
{ status },
|
|
);
|
|
}
|
|
|
|
function immediateClock() {
|
|
return { now: () => 0, sleep: async () => {} };
|
|
}
|
|
|
|
function recordingScheduler() {
|
|
const callbacks = /** @type {Array<() => void>} */ ([]);
|
|
return {
|
|
callbacks,
|
|
setTimeout: vi.fn((callback) => {
|
|
callbacks.push(callback);
|
|
return callbacks.length - 1;
|
|
}),
|
|
clearTimeout: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe("HTTP operation execution contract", () => {
|
|
it("sends parsed search/body values and aligns canonical query identity", async () => {
|
|
const requests = /** @type {Request[]} */ ([]);
|
|
const scheduler = recordingScheduler();
|
|
const fetcher = vi.fn(async (request) => {
|
|
requests.push(/** @type {Request} */ (request));
|
|
if (/** @type {Request} */ (request).method === "POST") {
|
|
return successResponse({ id: "created", name: "Trimmed" });
|
|
}
|
|
return successResponse([]);
|
|
});
|
|
const client = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
clock: immediateClock(),
|
|
scheduler,
|
|
});
|
|
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
|
|
|
|
await client.execute({
|
|
operationId: "LIST_SAMPLE_RESOURCES",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
searchParams: filters,
|
|
});
|
|
await client.execute({
|
|
operationId: "CREATE_SAMPLE_RESOURCE",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
body: { name: " Trimmed " },
|
|
idempotencyKey: "logical-command",
|
|
});
|
|
|
|
expect(requests[0].url).toBe(
|
|
"https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
|
);
|
|
expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters);
|
|
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
|
|
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
|
|
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
|
|
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("performs no fetch or timer work for invalid request input", async () => {
|
|
const fetcher = vi.fn();
|
|
const scheduler = recordingScheduler();
|
|
const client = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
scheduler,
|
|
});
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "CREATE_SAMPLE_RESOURCE",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
body: { name: " " },
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "VALIDATION_REJECTED" },
|
|
});
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
expect(scheduler.setTimeout).not.toHaveBeenCalled();
|
|
expect(scheduler.clearTimeout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
[0, 1],
|
|
[1, 2],
|
|
[2, 3],
|
|
])(
|
|
"applies runtime max retry count %i as %i total attempts",
|
|
async (maxRetryAttempts, totalAttempts) => {
|
|
const fetcher = vi.fn(async () => failureResponse(503));
|
|
const scheduler = recordingScheduler();
|
|
const client = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
clock: immediateClock(),
|
|
scheduler,
|
|
maxRetryAttempts,
|
|
});
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "LIST_SAMPLE_RESOURCES",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "SERVER_FAILURE" },
|
|
});
|
|
expect(fetcher).toHaveBeenCalledTimes(totalAttempts);
|
|
expect(scheduler.setTimeout).toHaveBeenCalledTimes(totalAttempts);
|
|
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(totalAttempts);
|
|
},
|
|
);
|
|
|
|
it("distinguishes a runtime timeout from caller navigation abort and cleans listeners", async () => {
|
|
const scheduler = recordingScheduler();
|
|
const fetcher = vi.fn(
|
|
(request) =>
|
|
new Promise((_resolve, reject) => {
|
|
/** @type {Request} */ (request).signal.addEventListener(
|
|
"abort",
|
|
() => reject(new DOMException("aborted", "AbortError")),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
);
|
|
const client = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
scheduler,
|
|
maxRetryAttempts: 0,
|
|
});
|
|
const timeoutResult = client.execute({
|
|
operationId: "LIST_SAMPLE_RESOURCES",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
});
|
|
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
|
|
scheduler.callbacks[0]();
|
|
await expect(timeoutResult).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "REQUEST_TIMEOUT" },
|
|
});
|
|
|
|
const caller = new AbortController();
|
|
const add = vi.spyOn(caller.signal, "addEventListener");
|
|
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
|
const abortResult = client.execute({
|
|
operationId: "LIST_SAMPLE_RESOURCES",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
signal: caller.signal,
|
|
});
|
|
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
|
caller.abort("navigation");
|
|
await expect(abortResult).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "REQUEST_ABORTED" },
|
|
});
|
|
expect(add).toHaveBeenCalledOnce();
|
|
expect(remove).toHaveBeenCalledOnce();
|
|
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("never retries unsafe, non-retryable status, or schema failures", async () => {
|
|
const unsafeOperation = /** @type {const} */ ({
|
|
method: "POST",
|
|
path: "/api/unsafe",
|
|
operationId: "UNSAFE",
|
|
auth: "none",
|
|
timeoutMs: null,
|
|
idempotency: "none",
|
|
retry: "never",
|
|
requestSource: "none",
|
|
requestSchema: "unused",
|
|
responseSchema: "SampleResourcePayload",
|
|
owner: "test",
|
|
});
|
|
const unsafeFetch = vi.fn(async () => failureResponse(503));
|
|
const unsafeClient = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher: unsafeFetch,
|
|
clock: immediateClock(),
|
|
getOperation: () => unsafeOperation,
|
|
});
|
|
await unsafeClient.execute({
|
|
operationId: "UNSAFE",
|
|
routeId: "TEST",
|
|
});
|
|
expect(unsafeFetch).toHaveBeenCalledOnce();
|
|
|
|
const statusFetch = vi.fn(async () => failureResponse(500));
|
|
const statusClient = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher: statusFetch,
|
|
clock: immediateClock(),
|
|
});
|
|
await statusClient.execute({
|
|
operationId: "LIST_SAMPLE_RESOURCES",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
});
|
|
expect(statusFetch).toHaveBeenCalledOnce();
|
|
|
|
const schemaFetch = vi.fn(async () =>
|
|
successResponse([{ id: "one", name: 42 }]),
|
|
);
|
|
const schemaScheduler = recordingScheduler();
|
|
const schemaClient = createHttpClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher: schemaFetch,
|
|
clock: immediateClock(),
|
|
scheduler: schemaScheduler,
|
|
});
|
|
await schemaClient.execute({
|
|
operationId: "LIST_SAMPLE_RESOURCES",
|
|
routeId: "SAMPLE_RESOURCE_LIST",
|
|
});
|
|
expect(schemaFetch).toHaveBeenCalledOnce();
|
|
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
|
|
});
|
|
});
|