feat: execute HTTP and query runtime contracts
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
renderHook,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
useApplicationMutation,
|
||||
useApplicationQuery,
|
||||
} from "../../src/presentation/adapters/query/application-query.js";
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
|
||||
function queryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: 0, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {QueryClient} client */
|
||||
function wrapper(client) {
|
||||
/** @param {{children: React.ReactNode}} props */
|
||||
return function QueryWrapper({ children }) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("application query inbound bridge", () => {
|
||||
it("latches a background failure over stale data and clears it on retry success", async () => {
|
||||
const client = queryClient();
|
||||
const responses = [
|
||||
{ ok: /** @type {const} */ (true), value: ["first"] },
|
||||
{
|
||||
ok: /** @type {const} */ (false),
|
||||
error: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
},
|
||||
{ ok: /** @type {const} */ (true), value: ["recovered"] },
|
||||
];
|
||||
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["resource", "list"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
|
||||
);
|
||||
expect(hook.result.current.state.base).toBe("success");
|
||||
expect(hook.result.current.data).toEqual(["first"]);
|
||||
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.data).toEqual(["recovered"]),
|
||||
);
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("projects an initial application failure into terminal state", async () => {
|
||||
const client = queryClient();
|
||||
const failure = createFailure("FORBIDDEN", "LIST", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["forbidden"],
|
||||
execute: async () => ({ ok: false, error: failure }),
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.base).toBe("terminal-error"),
|
||||
);
|
||||
expect(hook.result.current.state.failure).toBe(failure);
|
||||
});
|
||||
|
||||
it("passes cancellation to the application and does not retain an unmounted error", async () => {
|
||||
const client = queryClient();
|
||||
let aborted = false;
|
||||
const execute = vi.fn(
|
||||
({ signal }) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({
|
||||
ok: false,
|
||||
error: createFailure("REQUEST_ABORTED", "LIST", 0),
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["cancelled"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
hook.unmount();
|
||||
await waitFor(() => expect(aborted).toBe(true));
|
||||
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application mutation inbound bridge", () => {
|
||||
it("deduplicates submit and commits one optimistic mutation", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
/** @type {(value: {ok: true, value: string}) => void} */
|
||||
let complete = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation({
|
||||
execute,
|
||||
invalidate: [["resource"]],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
.../** @type {string[]} */ (previous),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
|
||||
let first = null;
|
||||
/** @type {ReturnType<typeof hook.result.current.submit> | null} */
|
||||
let duplicate = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("created");
|
||||
duplicate = hook.result.current.submit("created");
|
||||
});
|
||||
expect(first).toBe(duplicate);
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
|
||||
);
|
||||
|
||||
complete({ ok: true, value: "created" });
|
||||
if (!first) throw new Error("expected pending mutation");
|
||||
await act(() => first);
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
const conflict = createFailure("CONFLICT", "CREATE", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation({
|
||||
execute: async () => ({ ok: false, error: conflict }),
|
||||
invalidate: [["resource"]],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
.../** @type {string[]} */ (previous),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("conflicting");
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, error: conflict });
|
||||
expect(client.getQueryData(key)).toEqual(["existing"]);
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
|
||||
expect(hook.result.current.state.overlay).toMatchObject({
|
||||
mutationPending: false,
|
||||
mutationConflict: true,
|
||||
});
|
||||
|
||||
await act(() => hook.result.current.resolveConflict());
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
|
||||
@@ -29,7 +30,7 @@ describe("async UI state matrix", () => {
|
||||
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
||||
});
|
||||
|
||||
it("uses deterministic overlay priority for crossed states", () => {
|
||||
it("makes crossed overlay inputs mutually exclusive by priority", () => {
|
||||
const state = deriveAsyncState({
|
||||
data: ["value"],
|
||||
isFetching: true,
|
||||
@@ -38,8 +39,8 @@ describe("async UI state matrix", () => {
|
||||
});
|
||||
expect(state.indicator).toBe("mutation-conflict");
|
||||
expect(state.overlay).toMatchObject({
|
||||
refreshing: true,
|
||||
mutationPending: true,
|
||||
refreshing: false,
|
||||
mutationPending: false,
|
||||
mutationConflict: true,
|
||||
});
|
||||
});
|
||||
@@ -52,12 +53,45 @@ describe("async UI state matrix", () => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
|
||||
});
|
||||
|
||||
it("connects stale retry and conflict resolution to real callbacks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const resolveConflict = vi.fn();
|
||||
const stale = deriveAsyncState({
|
||||
data: ["value"],
|
||||
isStale: true,
|
||||
isDegraded: true,
|
||||
});
|
||||
const view = render(
|
||||
<AsyncSurface state={stale} onRetry={retry}>
|
||||
existing content
|
||||
</AsyncSurface>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
|
||||
const conflict = deriveAsyncState({
|
||||
data: ["value"],
|
||||
hasMutationConflict: true,
|
||||
});
|
||||
view.rerender(
|
||||
<AsyncSurface
|
||||
state={conflict}
|
||||
onResolveConflict={resolveConflict}
|
||||
>
|
||||
existing content
|
||||
</AsyncSurface>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
|
||||
expect(resolveConflict).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders only safe error vocabulary", () => {
|
||||
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
|
||||
code: "SERVER_FAILURE",
|
||||
});
|
||||
const state = deriveAsyncState({ failure });
|
||||
render(<AsyncSurface state={state} />);
|
||||
render(<AsyncSurface state={state} onRetry={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"요청을 완료하지 못했습니다.",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const leakedQueryHook = useQuery;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AsyncOverlay } from "../../../src/application/view-models/async-state.js";
|
||||
|
||||
export const invalidPendingConflict: AsyncOverlay = {
|
||||
refreshing: false,
|
||||
staleDegraded: false,
|
||||
mutationPending: true,
|
||||
mutationConflict: true,
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildRequestTarget } from "../../src/adapters/http/request-builder.js";
|
||||
import type { ApiOperation } from "../../src/contracts/api-operations.js";
|
||||
|
||||
const operation: ApiOperation = {
|
||||
method: "GET",
|
||||
path: "/api/resources/{resourceId}",
|
||||
operationId: "GET_RESOURCE",
|
||||
auth: "none",
|
||||
timeoutMs: null,
|
||||
idempotency: "safe",
|
||||
retry: "runtime",
|
||||
requestSource: "search",
|
||||
requestSchema: "ResourceQuery",
|
||||
responseSchema: "ResourcePayload",
|
||||
owner: "test",
|
||||
};
|
||||
|
||||
describe("deterministic HTTP request target", () => {
|
||||
it("escapes path values and serializes optional/array search in key order", () => {
|
||||
const result = buildRequestTarget(
|
||||
"https://api.test/base/",
|
||||
operation,
|
||||
{ resourceId: "folder/item" },
|
||||
{
|
||||
tags: ["beta", "alpha"],
|
||||
omitted: undefined,
|
||||
limit: 20,
|
||||
cursor: "next page",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.url.href).toBe(
|
||||
"https://api.test/api/resources/folder%2Fitem?cursor=next+page&limit=20&tags=beta&tags=alpha",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a path value or scalar search value is invalid", () => {
|
||||
expect(
|
||||
buildRequestTarget("https://api.test", operation, {}, {}),
|
||||
).toEqual({ success: false, code: "PATH_PARAMETER_MISSING" });
|
||||
expect(
|
||||
buildRequestTarget(
|
||||
"https://api.test",
|
||||
operation,
|
||||
{ resourceId: "one" },
|
||||
{ nested: { secret: true } },
|
||||
),
|
||||
).toEqual({ success: false, code: "SEARCH_PARAMETER_INVALID" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js";
|
||||
import {
|
||||
createRuntimeAdapters,
|
||||
createRuntimeHttpClient,
|
||||
} from "../../src/bootstrap/runtime-adapters.js";
|
||||
|
||||
const runtime = {
|
||||
config: {
|
||||
@@ -8,6 +11,8 @@ const runtime = {
|
||||
API_BASE_URL: "http://localhost:8080",
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
REQUEST_TIMEOUT_MS: 4321,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
},
|
||||
};
|
||||
const release = /** @type {const} */ ({
|
||||
@@ -53,4 +58,60 @@ describe("runtime adapter composition", () => {
|
||||
});
|
||||
expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
|
||||
});
|
||||
|
||||
it("injects runtime timeout and max-attempt policy into HTTP execution", async () => {
|
||||
const scheduled =
|
||||
/** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]);
|
||||
const scheduler = {
|
||||
setTimeout: vi.fn((callback, milliseconds) => {
|
||||
scheduled.push({ callback, milliseconds });
|
||||
return scheduled.length;
|
||||
}),
|
||||
clearTimeout: vi.fn(),
|
||||
};
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "TEMPORARY" },
|
||||
meta: { requestId: "request-1", traceId: "trace-1" },
|
||||
},
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
const authSession =
|
||||
(await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
release,
|
||||
host: {},
|
||||
})).outputPorts.session;
|
||||
const client = createRuntimeHttpClient({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
authSession:
|
||||
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
|
||||
authSession
|
||||
),
|
||||
fetcher,
|
||||
clock: { now: () => 0, sleep: async () => {} },
|
||||
scheduler,
|
||||
});
|
||||
|
||||
await client.execute({
|
||||
operationId: "LIST_SAMPLE_RESOURCES",
|
||||
routeId: "SAMPLE_RESOURCE_LIST",
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
expect(scheduler.setTimeout).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
4321,
|
||||
);
|
||||
expect(scheduler.clearTimeout).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,14 @@ import { systemClock } from "../../src/adapters/platform/system-clock.js";
|
||||
describe("systemClock", () => {
|
||||
it("resolves after the requested duration", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sleeper = systemClock.sleep(250);
|
||||
const caller = new AbortController();
|
||||
const add = vi.spyOn(caller.signal, "addEventListener");
|
||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||
const sleeper = systemClock.sleep(250, caller.signal);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await expect(sleeper).resolves.toBeUndefined();
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user