277 lines
7.8 KiB
TypeScript
277 lines
7.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
|
import type { InstalledHttpContract } from "../../src/contracts/external-contract-runtime.ts";
|
|
import {
|
|
TEST_CREATE_HTTP_CONTRACT,
|
|
TEST_LIST_HTTP_CONTRACT,
|
|
} from "../helpers/external-contract-fixture.ts";
|
|
|
|
const installed: InstalledHttpContract<unknown, unknown, unknown> =
|
|
TEST_LIST_HTTP_CONTRACT;
|
|
|
|
const scope = Object.freeze({
|
|
generation: 1,
|
|
fingerprint: "scope-1",
|
|
identities: Object.freeze({}) as never,
|
|
signal: new AbortController().signal,
|
|
isCurrent: () => true,
|
|
});
|
|
|
|
const createInstalled: InstalledHttpContract<unknown, unknown, unknown> =
|
|
TEST_CREATE_HTTP_CONTRACT;
|
|
|
|
function operation(
|
|
overrides: Readonly<{
|
|
deadlineMs?: number;
|
|
responseBody?: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE";
|
|
}> = {},
|
|
): InstalledHttpContract<unknown, unknown, unknown> {
|
|
return {
|
|
...installed,
|
|
contract: {
|
|
...installed.contract,
|
|
responseBody: overrides.responseBody ?? installed.contract.responseBody,
|
|
},
|
|
frontend: {
|
|
...installed.frontend,
|
|
totalDeadlineMs: overrides.deadlineMs ?? installed.frontend.totalDeadlineMs,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe("descriptor-driven HTTP execution lifetime", () => {
|
|
it.each([
|
|
[{}, "limit=20"],
|
|
[{ limit: "7" }, "limit=7"],
|
|
])("projects the canonical validated input %#", async (input, expectedQuery) => {
|
|
const fetcher = vi.fn(async () => Response.json([]));
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials: () => ({
|
|
kind: "READY",
|
|
headers: {},
|
|
credentials: "omit",
|
|
}),
|
|
fetcher,
|
|
});
|
|
|
|
await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({
|
|
kind: "SUCCESS",
|
|
});
|
|
expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain(
|
|
expectedQuery,
|
|
);
|
|
});
|
|
|
|
it("contains throwing and malformed external request projections", async () => {
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials: () => ({
|
|
kind: "READY",
|
|
headers: {},
|
|
credentials: "omit",
|
|
}),
|
|
fetcher: vi.fn(),
|
|
});
|
|
const throwing = {
|
|
...installed,
|
|
contract: {
|
|
...installed.contract,
|
|
projectRequest: () => {
|
|
throw new Error("external descriptor defect");
|
|
},
|
|
},
|
|
};
|
|
const malformed = {
|
|
...installed,
|
|
contract: {
|
|
...installed.contract,
|
|
projectRequest: () => ({ pathValues: {}, queryEntries: [["limit"]], body: null }),
|
|
},
|
|
} as unknown as typeof installed;
|
|
|
|
await expect(executor.execute(throwing, { limit: 20 }, { scope })).resolves.toMatchObject({
|
|
kind: "CONTRACT_VIOLATION",
|
|
effect: "NOT_APPLICABLE",
|
|
});
|
|
await expect(executor.execute(malformed, { limit: 20 }, { scope })).resolves.toMatchObject({
|
|
kind: "CONTRACT_VIOLATION",
|
|
effect: "NOT_APPLICABLE",
|
|
});
|
|
});
|
|
|
|
it("preserves MAYBE_APPLIED for a malformed command response after dispatch", async () => {
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials: () => ({
|
|
kind: "READY",
|
|
headers: {},
|
|
credentials: "omit",
|
|
}),
|
|
fetcher: vi.fn(async () => Response.json({ malformed: true }, { status: 201 })),
|
|
});
|
|
|
|
await expect(
|
|
executor.execute(
|
|
createInstalled,
|
|
{ name: "created" },
|
|
{
|
|
scope,
|
|
intent: { intentId: "intent-1", startedBy: "USER", idempotencyKey: "key-1" },
|
|
},
|
|
),
|
|
).resolves.toMatchObject({
|
|
kind: "CONTRACT_VIOLATION",
|
|
violation: { kind: "SUCCESS_SCHEMA_INVALID" },
|
|
effect: "MAYBE_APPLIED",
|
|
});
|
|
});
|
|
|
|
it("preserves MAYBE_APPLIED when a command response arrives after its scope fence", async () => {
|
|
let current = true;
|
|
const lifetime = new AbortController();
|
|
const fencedScope = Object.freeze({
|
|
...scope,
|
|
signal: lifetime.signal,
|
|
isCurrent: () => current,
|
|
});
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials: () => ({
|
|
kind: "READY",
|
|
headers: {},
|
|
credentials: "omit",
|
|
}),
|
|
fetcher: vi.fn(async () => {
|
|
current = false;
|
|
lifetime.abort();
|
|
return Response.json(
|
|
{ id: "created", name: "Created" },
|
|
{ status: 201 },
|
|
);
|
|
}),
|
|
});
|
|
|
|
await expect(
|
|
executor.execute(
|
|
createInstalled,
|
|
{ name: "created" },
|
|
{
|
|
scope: fencedScope,
|
|
intent: { intentId: "intent-2", startedBy: "USER", idempotencyKey: "key-2" },
|
|
},
|
|
),
|
|
).resolves.toMatchObject({
|
|
kind: "CONTRACT_VIOLATION",
|
|
violation: { kind: "SCOPE_FENCED" },
|
|
effect: "MAYBE_APPLIED",
|
|
});
|
|
});
|
|
|
|
it("settles a credential hang at the total operation deadline", async () => {
|
|
vi.useFakeTimers();
|
|
let settled = false;
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials: () => new Promise(() => {}),
|
|
});
|
|
|
|
const result = executor
|
|
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope })
|
|
.then((outcome) => {
|
|
settled = true;
|
|
return outcome;
|
|
});
|
|
await vi.advanceTimersByTimeAsync(5);
|
|
await flushMicrotasks();
|
|
|
|
expect(settled).toBe(true);
|
|
await expect(result).resolves.toMatchObject({
|
|
kind: "TRANSPORT_FAILURE",
|
|
failure: { kind: "TIMEOUT" },
|
|
});
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("cancels a non-cooperative retry sleep when the caller aborts", async () => {
|
|
const caller = new AbortController();
|
|
let sleepSignal: AbortSignal | undefined;
|
|
let settled = false;
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 2,
|
|
attachCredentials: () => ({
|
|
kind: "READY",
|
|
headers: {},
|
|
credentials: "omit",
|
|
}),
|
|
fetcher: vi.fn(async () =>
|
|
Response.json(
|
|
{ type: "about:blank", title: "temporary", status: 503 },
|
|
{ status: 503 },
|
|
),
|
|
),
|
|
sleep: (_ms, signal) => {
|
|
sleepSignal = signal;
|
|
return new Promise(() => {});
|
|
},
|
|
random: () => 0,
|
|
});
|
|
|
|
const result = executor
|
|
.execute(installed, { limit: 20 }, { scope, signal: caller.signal })
|
|
.then((outcome) => {
|
|
settled = true;
|
|
return outcome;
|
|
});
|
|
await vi.waitFor(() => expect(sleepSignal).toBeDefined());
|
|
caller.abort();
|
|
await flushMicrotasks();
|
|
|
|
expect(sleepSignal?.aborted).toBe(true);
|
|
expect(settled).toBe(true);
|
|
await expect(result).resolves.toMatchObject({ kind: "CANCELLED" });
|
|
});
|
|
|
|
it("treats an unreadable forbidden-body probe as a transport failure", async () => {
|
|
const body = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
controller.error(new TypeError("stream failed"));
|
|
},
|
|
});
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials: () => ({
|
|
kind: "READY",
|
|
headers: {},
|
|
credentials: "omit",
|
|
}),
|
|
fetcher: vi.fn(async () => new Response(body, { status: 200 })),
|
|
});
|
|
|
|
await expect(
|
|
executor.execute(
|
|
operation({ responseBody: "NONE" }),
|
|
{ limit: 20 },
|
|
{ scope },
|
|
),
|
|
).resolves.toMatchObject({
|
|
kind: "TRANSPORT_FAILURE",
|
|
failure: { kind: "RESPONSE_STREAM_FAILURE" },
|
|
});
|
|
});
|
|
});
|