Files
clean-architecture-frontend…/tests/unit/http-execution-v3.test.ts
T

628 lines
18 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 mutationIntent(
overrides: Readonly<{
intentId?: string;
operationId?: string;
idempotencyKey?: string | null;
}> = {},
) {
return Object.freeze({
intentId: overrides.intentId ?? "intent-1",
operationId: overrides.operationId ?? "TEST_CREATE_ENTITY",
canonicalInputIdentity: "opaque-input-identity",
...(overrides.idempotencyKey === null
? {}
: { idempotencyKey: overrides.idempotencyKey ?? "key-1" }),
createdAtMonotonicMs: 1,
});
}
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("normalizes a read-side 429 to the non-applicable effect vocabulary", async () => {
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: async () =>
Response.json(
{ type: "about:blank", title: "limited", status: 429 },
{ status: 429 },
),
});
await expect(
executor.execute(installed, { limit: 20 }, { scope }),
).resolves.toMatchObject({
kind: "RATE_LIMITED",
effect: "NOT_APPLICABLE",
});
});
it.each([
["absent intent", () => undefined],
["empty key", () => mutationIntent({ idempotencyKey: "" })],
["control-character key", () => mutationIntent({ idempotencyKey: "key\u0000private" })],
["over-budget key", () => mutationIntent({ idempotencyKey: "k".repeat(257) })],
[
"wrong operation",
() => mutationIntent({ operationId: "TEST_OTHER_COMMAND" }),
],
])(
"rejects a KEYED command with %s before credentials or fetch",
async (_label, intentFactory) => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials,
fetcher,
});
const intent = intentFactory();
await expect(
executor.execute(
createInstalled,
{ name: "created" },
{ scope, ...(intent === undefined ? {} : { intent }) },
),
).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION",
violation: {
kind: "MISSING_IDEMPOTENCY_KEY",
operation: "REQUEST",
},
effect: "NOT_STARTED",
});
expect(attachCredentials).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
},
);
it.each([
["an intent without a key", mutationIntent({ idempotencyKey: null })],
["an intent with a key", mutationIntent()],
])(
"rejects a query carrying %s before credentials or fetch",
async (_label, intent) => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials,
fetcher,
});
await expect(
executor.execute(installed, { limit: 20 }, { scope, intent }),
).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION",
violation: {
kind: "UNEXPECTED_IDEMPOTENCY_KEY",
operation: "REQUEST",
},
effect: "NOT_STARTED",
});
expect(attachCredentials).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
},
);
it.each([
{
label: "query with the canonical reserved header",
operation: installed,
input: { limit: 20 },
context: { scope },
headerName: "Idempotency-Key",
},
{
label: "valid KEYED command with a case-variant reserved header",
operation: createInstalled,
input: { name: "created" },
context: { scope, intent: mutationIntent() },
headerName: "iDeMpOtEnCy-KeY",
},
])(
"rejects a credential patch for $label before fetch",
async ({ operation, input, context, headerName }) => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: { [headerName]: "credential-owned-key" },
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials,
fetcher,
});
await expect(
executor.execute(operation, input, context),
).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION",
violation: {
kind: "UNEXPECTED_IDEMPOTENCY_KEY",
operation: "REQUEST",
},
effect: "NOT_STARTED",
});
expect(attachCredentials).toHaveBeenCalledOnce();
expect(fetcher).not.toHaveBeenCalled();
},
);
it("reuses one supplied idempotency key across every physical retry", async () => {
const observedKeys: Array<string | null> = [];
let attempt = 0;
const fetcher = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
observedKeys.push(new Headers(init?.headers).get("Idempotency-Key"));
attempt += 1;
if (attempt === 1) throw new TypeError("synchronous pre-dispatch failure");
return Promise.resolve(
Response.json(
{ id: "created", name: "Created" },
{ status: 201 },
),
);
});
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 1,
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
sleep: async () => {},
random: () => 0,
});
const retryingCreate = {
...createInstalled,
frontend: {
...createInstalled.frontend,
retryBudget: 1 as const,
},
};
await expect(
executor.execute(
retryingCreate,
{ name: "created" },
{ scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) },
),
).resolves.toMatchObject({ kind: "SUCCESS" });
expect(fetcher).toHaveBeenCalledTimes(2);
expect(observedKeys).toEqual(["logical-key", "logical-key"]);
});
it("never emits a mutation idempotency header for a query", async () => {
const observedKeys: Array<string | null> = [];
const fetcher = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) => {
observedKeys.push(
new Headers(init?.headers).get("Idempotency-Key"),
);
return Response.json([]);
},
);
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
});
await expect(
executor.execute(
installed,
{ limit: 20 },
{ scope },
),
).resolves.toMatchObject({ kind: "SUCCESS" });
expect(fetcher).toHaveBeenCalledOnce();
expect(observedKeys).toEqual([null]);
});
it("keeps intent identities out of request URLs and safe observations", async () => {
const urls: string[] = [];
const observations: unknown[] = [];
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(async (input) => {
urls.push(String(input));
return Response.json(
{ id: "created", name: "Created" },
{ status: 201 },
);
}),
observe: (observation) => observations.push(observation),
});
await expect(
executor.execute(
createInstalled,
{ name: "created" },
{
scope,
intent: Object.freeze({
intentId: "private-intent-id",
operationId: "TEST_CREATE_ENTITY",
canonicalInputIdentity: "private-canonical-input",
idempotencyKey: "private-idempotency-key",
createdAtMonotonicMs: 1,
}),
},
),
).resolves.toMatchObject({ kind: "SUCCESS" });
const safeEvidence = JSON.stringify({ urls, observations });
expect(urls).toEqual(["https://api.example/api/test-entities"]);
expect(observations).toHaveLength(1);
expect(safeEvidence).not.toContain("private-intent-id");
expect(safeEvidence).not.toContain("private-canonical-input");
expect(safeEvidence).not.toContain("private-idempotency-key");
});
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: mutationIntent(),
},
),
).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: mutationIntent({ intentId: "intent-2", 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("never retries a deadline-owned abort while the monotonic clock still has a sub-tick budget", async () => {
vi.useFakeTimers();
try {
const iterationCount = 100;
const sleep = vi.fn(async () => {
throw new Error("a deadline-owned abort must not enter retry sleep");
});
const fetcher = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(new DOMException("Aborted", "AbortError")),
{ once: true },
);
}),
);
const observe = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 2,
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
monotonicNow: () => 0,
random: () => 0,
sleep,
observe,
});
for (let iteration = 0; iteration < iterationCount; iteration += 1) {
const result = executor.execute(
operation({ deadlineMs: 5 }),
{ limit: 20 },
{ scope },
);
await vi.advanceTimersByTimeAsync(5);
await flushMicrotasks();
await expect(result).resolves.toMatchObject({
kind: "TRANSPORT_FAILURE",
failure: { kind: "TIMEOUT" },
});
}
expect(fetcher).toHaveBeenCalledTimes(iterationCount);
expect(sleep).not.toHaveBeenCalled();
expect(observe).toHaveBeenCalledTimes(iterationCount);
for (const [observation] of observe.mock.calls) {
expect(observation).toEqual(
expect.objectContaining({ attempts: 1, certainty: "TIMEOUT" }),
);
}
} finally {
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" },
});
});
});