194 lines
5.3 KiB
TypeScript
194 lines
5.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createHttpClient } from "../../src/adapters/http/client.js";
|
|
import type { DiagnosticRecordInput } from "../../src/contracts/diagnostics.js";
|
|
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
|
|
|
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 },
|
|
);
|
|
}
|
|
|
|
type EmittedEvent = Readonly<{
|
|
eventName: string;
|
|
attributes: Readonly<Record<string, unknown>>;
|
|
}>;
|
|
|
|
function harness(fetcher: typeof fetch, maxRetryAttempts = 1) {
|
|
const records: DiagnosticRecordInput[] = [];
|
|
const emitted: EmittedEvent[] = [];
|
|
let currentTime = 0;
|
|
const client = createHttpClient({
|
|
...TEST_HTTP_CONTRACT,
|
|
baseUrl: "https://api.test",
|
|
fetcher,
|
|
maxRetryAttempts,
|
|
clock: {
|
|
now: () => currentTime,
|
|
sleep: async () => {
|
|
currentTime += 150;
|
|
},
|
|
},
|
|
scheduler: {
|
|
setTimeout: () => 1,
|
|
clearTimeout: () => {},
|
|
},
|
|
correlationIdFactory: () => "correlation-fixed",
|
|
diagnostics: {
|
|
record(input) {
|
|
records.push(structuredClone(input));
|
|
},
|
|
},
|
|
telemetry: {
|
|
emit(eventName, attributes) {
|
|
emitted.push({
|
|
eventName,
|
|
attributes: structuredClone(attributes),
|
|
});
|
|
},
|
|
},
|
|
});
|
|
return { client, records, emitted };
|
|
}
|
|
|
|
describe("HTTP diagnostics and terminal telemetry", () => {
|
|
it("records one successful reference route outcome and no failure event", async () => {
|
|
const { client, records, emitted } = harness(
|
|
vi.fn(async () => successResponse([])),
|
|
);
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
}),
|
|
).resolves.toMatchObject({ ok: true });
|
|
|
|
expect(records).toEqual([
|
|
{
|
|
level: "info",
|
|
eventId: "http.request.completed",
|
|
context: {
|
|
route_id: "TEST_ROUTE",
|
|
operation_id: "LIST_ENTITIES",
|
|
correlation_id: "correlation-fixed",
|
|
outcome: "success",
|
|
error_kind: "NONE",
|
|
http_status_group: "none",
|
|
attempt_count_bucket: "1",
|
|
duration_bucket: "lt100ms",
|
|
},
|
|
},
|
|
]);
|
|
expect(emitted).toEqual([]);
|
|
});
|
|
|
|
it("summarizes retry recovery once without a terminal failure event", async () => {
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(failureResponse(503))
|
|
.mockResolvedValueOnce(successResponse([]));
|
|
const { client, records, emitted } = harness(fetcher);
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
}),
|
|
).resolves.toMatchObject({ ok: true });
|
|
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]).toMatchObject({
|
|
eventId: "http.request.completed",
|
|
context: {
|
|
outcome: "recovered",
|
|
correlation_id: "correlation-fixed",
|
|
duration_bucket: "100-499ms",
|
|
},
|
|
});
|
|
expect(emitted).toEqual([]);
|
|
});
|
|
|
|
it("emits one bounded terminal failure after all attempts", async () => {
|
|
const fetcher = vi.fn(async () => failureResponse(503));
|
|
const { client, records, emitted } = harness(fetcher);
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "CREATE_ENTITY",
|
|
routeId: "TEST_ROUTE",
|
|
body: { name: "private user input" },
|
|
idempotencyKey: "private-idempotency-key",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "SERVER_FAILURE" },
|
|
});
|
|
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
expect(records).toHaveLength(1);
|
|
expect(emitted).toEqual([
|
|
{
|
|
eventName: "api.request.failed",
|
|
attributes: {
|
|
error_kind: "SERVER_FAILURE",
|
|
http_status_group: "5xx",
|
|
attempt_count_bucket: "2",
|
|
route_id: "TEST_ROUTE",
|
|
operation_id: "CREATE_ENTITY",
|
|
duration_bucket: "100-499ms",
|
|
},
|
|
},
|
|
]);
|
|
expect(JSON.stringify({ records, emitted })).not.toMatch(
|
|
/private user input|private-idempotency-key/,
|
|
);
|
|
});
|
|
|
|
it("records navigation abort without failure telemetry", async () => {
|
|
const caller = new AbortController();
|
|
caller.abort("navigation");
|
|
const fetcher = vi.fn(async (request: RequestInfo | URL) => {
|
|
const signal = (request as Request).signal;
|
|
if (signal.aborted) throw new DOMException("aborted", "AbortError");
|
|
return successResponse([]);
|
|
});
|
|
const { client, records, emitted } = harness(
|
|
fetcher as unknown as typeof fetch,
|
|
);
|
|
|
|
await expect(
|
|
client.execute({
|
|
operationId: "LIST_ENTITIES",
|
|
routeId: "TEST_ROUTE",
|
|
signal: caller.signal,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { kind: "REQUEST_ABORTED" },
|
|
});
|
|
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]).toMatchObject({
|
|
eventId: "http.request.completed",
|
|
context: { outcome: "aborted", error_kind: "REQUEST_ABORTED" },
|
|
});
|
|
expect(emitted).toEqual([]);
|
|
});
|
|
});
|