feat: execute HTTP and query runtime contracts
This commit is contained in:
@@ -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