Separate per-attempt physical state from the logical execution history. The executor now keeps one monotonic certainty accumulator joined through joinMutationEffectCertainty, records MAYBE_APPLIED at dispatch, and reads the accumulator from every retry-loop fence, final-invariant, cancellation and timeout return. A retry-time scope fence landing between the loop-entry check and the pre-dispatch invariant can no longer downgrade an already dispatched command to NOT_STARTED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createHttpClient } from "../../src/adapters/http/client.ts";
|
|
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
|
import { joinMutationEffectCertainty } from "../../src/adapters/http/http-effect-certainty.ts";
|
|
import {
|
|
entityQueryKeys,
|
|
TEST_HTTP_CONTRACT,
|
|
} from "../helpers/http-contract-fixture.ts";
|
|
import { TEST_CREATE_HTTP_CONTRACT } from "../helpers/external-contract-fixture.ts";
|
|
|
|
function successResponse(data: unknown) {
|
|
return Response.json({
|
|
success: true,
|
|
data,
|
|
meta: { requestId: "request-1", traceId: "trace-1" },
|
|
});
|
|
}
|
|
|
|
function failureResponse(status: number) {
|
|
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: Array<() => void> = [];
|
|
return {
|
|
callbacks,
|
|
setTimeout: vi.fn((callback: () => void) => {
|
|
callbacks.push(callback);
|
|
return callbacks.length - 1;
|
|
}),
|
|
clearTimeout: vi.fn(),
|
|
};
|
|
}
|
|
|
|
type HttpDependencies = Parameters<typeof createHttpClient>[0];
|
|
|
|
function testClient(options: HttpDependencies) {
|
|
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
|
}
|
|
|
|
describe("mutation effect certainty lattice", () => {
|
|
it.each([
|
|
["NOT_STARTED", "NOT_APPLIED", "NOT_APPLIED"],
|
|
["NOT_APPLIED", "NOT_STARTED", "NOT_APPLIED"],
|
|
["NOT_STARTED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
|
|
["MAYBE_APPLIED", "NOT_STARTED", "MAYBE_APPLIED"],
|
|
["MAYBE_APPLIED", "NOT_APPLIED", "MAYBE_APPLIED"],
|
|
["NOT_APPLIED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
|
|
["MAYBE_APPLIED", "APPLIED_CONFIRMED", "APPLIED_CONFIRMED"],
|
|
["APPLIED_CONFIRMED", "NOT_STARTED", "APPLIED_CONFIRMED"],
|
|
["APPLIED_CONFIRMED", "MAYBE_APPLIED", "APPLIED_CONFIRMED"],
|
|
] as const)("joins %s with %s as %s", (current, observed, expected) => {
|
|
expect(joinMutationEffectCertainty(current, observed)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("HTTP operation execution contract", () => {
|
|
it("permits a keyless command intent but rejects an unexpected key before dispatch", async () => {
|
|
const attachCredentials = vi.fn(() => ({
|
|
kind: "READY" as const,
|
|
headers: {},
|
|
credentials: "omit" as const,
|
|
}));
|
|
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(
|
|
{ id: "created", name: "Created" },
|
|
{ status: 201 },
|
|
);
|
|
},
|
|
);
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
attachCredentials,
|
|
fetcher,
|
|
});
|
|
const nonKeyedCommand = {
|
|
...TEST_CREATE_HTTP_CONTRACT,
|
|
contract: {
|
|
...TEST_CREATE_HTTP_CONTRACT.contract,
|
|
retrySemantics: "NEVER" as const,
|
|
commandRecovery: null,
|
|
},
|
|
};
|
|
const scope = Object.freeze({
|
|
generation: 1,
|
|
fingerprint: "scope-1",
|
|
identities: Object.freeze({}) as never,
|
|
signal: new AbortController().signal,
|
|
isCurrent: () => true,
|
|
});
|
|
const intent = Object.freeze({
|
|
intentId: "intent-1",
|
|
operationId: "TEST_CREATE_ENTITY",
|
|
canonicalInputIdentity: "opaque-input-identity",
|
|
createdAtMonotonicMs: 1,
|
|
});
|
|
|
|
await expect(
|
|
executor.execute(
|
|
nonKeyedCommand,
|
|
{ name: "created" },
|
|
{ routeId: "TEST_ROUTE", scope, intent },
|
|
),
|
|
).resolves.toMatchObject({ kind: "SUCCESS" });
|
|
expect(observedKeys).toEqual([null]);
|
|
|
|
attachCredentials.mockClear();
|
|
fetcher.mockClear();
|
|
await expect(
|
|
executor.execute(
|
|
nonKeyedCommand,
|
|
{ name: "created" },
|
|
{ routeId: "TEST_ROUTE", scope, intent: { ...intent, idempotencyKey: "unexpected-key" } },
|
|
),
|
|
).resolves.toMatchObject({
|
|
kind: "CONTRACT_VIOLATION",
|
|
violation: {
|
|
kind: "UNEXPECTED_IDEMPOTENCY_KEY",
|
|
operation: "REQUEST",
|
|
},
|
|
effect: "NOT_STARTED",
|
|
});
|
|
expect(attachCredentials).not.toHaveBeenCalled();
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails auth-required execution before fetch when the session integration is unavailable", async () => {
|
|
const fetcher = vi.fn();
|
|
const client = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
authSession: undefined,
|
|
});
|
|
|
|
await expect(
|
|
client.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE" }),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "AUTH_INTEGRATION_FAILURE" },
|
|
});
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an oversized response without materializing its JSON", async () => {
|
|
const largeOperation = {
|
|
...TEST_HTTP_CONTRACT.getOperation("LIST_ENTITIES"),
|
|
maxResponseBytes: 32,
|
|
responseMediaTypes: ["application/json"],
|
|
successStatuses: [200],
|
|
};
|
|
const client = testClient({
|
|
baseUrl: "https://api.test",
|
|
getOperation: () => largeOperation,
|
|
fetcher: vi.fn(async () =>
|
|
Response.json({
|
|
success: true,
|
|
data: [{ id: "one", name: "a".repeat(100) }],
|
|
meta: { requestId: "request-1", traceId: "trace-1" },
|
|
}),
|
|
),
|
|
});
|
|
|
|
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "RESPONSE_BODY_LIMIT" },
|
|
});
|
|
});
|
|
|
|
it("sends parsed search/body values and aligns canonical query identity", async () => {
|
|
const requests: Request[] = [];
|
|
const scheduler = recordingScheduler();
|
|
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
|
const request = input as Request;
|
|
requests.push(request);
|
|
if (request.method === "POST") {
|
|
return successResponse({ id: "created", name: "Trimmed" });
|
|
}
|
|
return successResponse([]);
|
|
});
|
|
const client = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
clock: immediateClock(),
|
|
scheduler,
|
|
});
|
|
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
|
|
|
|
await client.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
searchParams: filters,
|
|
});
|
|
await client.execute({
|
|
operationId: "CREATE_ENTITY",
|
|
routeId: "TEST_ROUTE",
|
|
body: { name: " Trimmed " },
|
|
idempotencyKey: "logical-command",
|
|
});
|
|
|
|
expect(requests[0].url).toBe(
|
|
"https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new",
|
|
);
|
|
expect(requests[0].headers.get("Idempotency-Key")).toBeNull();
|
|
expect(entityQueryKeys.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(requests[1].url).not.toContain("logical-command");
|
|
expect(requests[0].headers.get("X-Correlation-ID")).toBeTruthy();
|
|
expect(requests[0].credentials).toBe("same-origin");
|
|
expect(requests[0].cache).toBe("no-store");
|
|
expect(requests[0].redirect).toBe("error");
|
|
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 = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
scheduler,
|
|
});
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "CREATE_ENTITY",
|
|
routeId: "TEST_ROUTE",
|
|
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 = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
clock: immediateClock(),
|
|
scheduler,
|
|
maxRetryAttempts,
|
|
});
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
}),
|
|
).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(
|
|
(input: RequestInfo | URL) =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
(input as Request).signal.addEventListener(
|
|
"abort",
|
|
() => reject(new DOMException("aborted", "AbortError")),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
);
|
|
const client = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
scheduler,
|
|
maxRetryAttempts: 0,
|
|
});
|
|
const timeoutResult = client.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
});
|
|
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_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
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 = {
|
|
method: "POST",
|
|
path: "/api/unsafe",
|
|
operationId: "UNSAFE",
|
|
auth: "none",
|
|
timeoutMs: null,
|
|
idempotency: "none",
|
|
retry: "never",
|
|
requestSource: "none",
|
|
requestSchema: "unused",
|
|
responseSchema: "EntityPayload",
|
|
owner: "test",
|
|
} as const;
|
|
const unsafeFetch = vi.fn(async () => failureResponse(503));
|
|
const unsafeClient = testClient({
|
|
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 = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher: statusFetch,
|
|
clock: immediateClock(),
|
|
});
|
|
await statusClient.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
});
|
|
expect(statusFetch).toHaveBeenCalledOnce();
|
|
|
|
const schemaFetch = vi.fn(async () =>
|
|
successResponse([{ id: "one", name: 42 }]),
|
|
);
|
|
const schemaScheduler = recordingScheduler();
|
|
const schemaClient = testClient({
|
|
baseUrl: "https://api.test",
|
|
fetcher: schemaFetch,
|
|
clock: immediateClock(),
|
|
scheduler: schemaScheduler,
|
|
});
|
|
await schemaClient.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
});
|
|
expect(schemaFetch).toHaveBeenCalledOnce();
|
|
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
|
|
});
|
|
});
|