227 lines
6.4 KiB
TypeScript
227 lines
6.4 KiB
TypeScript
import { delay, http, HttpResponse, type JsonBodyType } from "msw";
|
|
|
|
import type {
|
|
HttpScenarioId,
|
|
HttpScenarioOperationId,
|
|
} from "../scenarios/catalog.ts";
|
|
import { assertOperationScenario } from "../scenarios/catalog.ts";
|
|
|
|
export type ReferenceResourceFixture = Readonly<{
|
|
id: string;
|
|
name: string;
|
|
createdAt?: string;
|
|
}>;
|
|
|
|
type ScenarioOptions = Readonly<{
|
|
baseUrl?: string;
|
|
scenarios?: Partial<
|
|
Record<
|
|
| "LIST_REFERENCE_RESOURCES"
|
|
| "CREATE_REFERENCE_RESOURCE"
|
|
| "GET_REFERENCE_RESOURCE",
|
|
HttpScenarioId
|
|
>
|
|
>;
|
|
resources?: ReferenceResourceFixture[];
|
|
onAttempt?(attempt: Readonly<{
|
|
operationId: HttpScenarioOperationId;
|
|
scenarioId: HttpScenarioId;
|
|
attempt: number;
|
|
}>): void;
|
|
onList?(search: string): void;
|
|
onCreate?(body: Readonly<Record<string, unknown>>): void;
|
|
}>;
|
|
|
|
const DEFAULT_RESOURCE = Object.freeze({
|
|
id: "reference-1",
|
|
name: "Reference",
|
|
createdAt: "2026-07-26T00:00:00.000Z",
|
|
});
|
|
|
|
async function scenarioResponse(
|
|
operationId: HttpScenarioOperationId,
|
|
scenario: HttpScenarioId,
|
|
payload: JsonBodyType,
|
|
attempt: number,
|
|
) {
|
|
if (scenario === "slow") await delay(50);
|
|
if (scenario === "timeout" || scenario === "aborted") {
|
|
await delay("infinite");
|
|
}
|
|
if (scenario === "network-error") return HttpResponse.error();
|
|
if (scenario === "content-type-mismatch") {
|
|
return new HttpResponse("<html>not json</html>", {
|
|
headers: { "Content-Type": "text/html" },
|
|
});
|
|
}
|
|
if (scenario === "malformed-json") {
|
|
return new HttpResponse("{invalid", {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (scenario === "envelope-mismatch") {
|
|
return HttpResponse.json({ data: payload });
|
|
}
|
|
if (scenario === "schema-mismatch") {
|
|
return HttpResponse.json({ unexpected: true });
|
|
}
|
|
if (
|
|
scenario === "unauthenticated-401"
|
|
) {
|
|
return HttpResponse.json(problem(401, "AUTH_REQUIRED"), {
|
|
status: 401,
|
|
});
|
|
}
|
|
if (scenario === "forbidden-403") {
|
|
return HttpResponse.json(problem(403, "FORBIDDEN"), { status: 403 });
|
|
}
|
|
if (scenario === "not-found-404") {
|
|
return HttpResponse.json(problem(404, "NOT_FOUND"), { status: 404 });
|
|
}
|
|
if (scenario === "conflict-409") {
|
|
return HttpResponse.json(problem(409, "CONFLICT"), { status: 409 });
|
|
}
|
|
if (scenario === "validation-422") {
|
|
return HttpResponse.json(
|
|
problem(422, "VALIDATION_REJECTED"),
|
|
{ status: 422 },
|
|
);
|
|
}
|
|
if (scenario === "rate-limited-429") {
|
|
return HttpResponse.json(problem(429, "RATE_LIMITED"), {
|
|
status: 429,
|
|
headers: { "Retry-After": "1" },
|
|
});
|
|
}
|
|
if (
|
|
scenario === "server-terminal-503" ||
|
|
(scenario === "server-retry-success" && attempt === 1)
|
|
) {
|
|
return HttpResponse.json(problem(503, "SERVER_FAILURE"), {
|
|
status: 503,
|
|
});
|
|
}
|
|
if (scenario === "response-too-large") {
|
|
const ceiling =
|
|
operationId === "LIST_REFERENCE_RESOURCES" ? 262_144 : 32_768;
|
|
const bytes = new Uint8Array(ceiling + 1).fill(0x20);
|
|
bytes[0] = 0x5b;
|
|
bytes[bytes.length - 1] = 0x5d;
|
|
return new HttpResponse(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(bytes);
|
|
controller.close();
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": String(bytes.byteLength),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
return HttpResponse.json(payload);
|
|
}
|
|
|
|
function problem(status: number, code: string) {
|
|
return Object.freeze({
|
|
type: `https://api.test/problems/${code.toLowerCase()}`,
|
|
title: code,
|
|
status,
|
|
code,
|
|
});
|
|
}
|
|
|
|
export function createReferenceScenarioHandlers(options: ScenarioOptions = {}) {
|
|
const baseUrl = options.baseUrl ?? "https://api.test";
|
|
const resources = options.resources ?? [{ ...DEFAULT_RESOURCE }];
|
|
const attempts = new Map<string, number>();
|
|
const scenarioFor = (
|
|
operationId: keyof NonNullable<ScenarioOptions["scenarios"]>,
|
|
) =>
|
|
assertOperationScenario(
|
|
operationId,
|
|
options.scenarios?.[operationId] ?? "success",
|
|
);
|
|
const nextAttempt = (operationId: string) => {
|
|
const next = (attempts.get(operationId) ?? 0) + 1;
|
|
attempts.set(operationId, next);
|
|
return next;
|
|
};
|
|
|
|
return [
|
|
http.get(`${baseUrl}/api/reference-resources`, ({ request }) => {
|
|
options.onList?.(new URL(request.url).search);
|
|
const scenario = scenarioFor("LIST_REFERENCE_RESOURCES");
|
|
const payload = scenario === "empty" ? [] : resources;
|
|
const attempt = nextAttempt("LIST_REFERENCE_RESOURCES");
|
|
options.onAttempt?.({
|
|
operationId: "LIST_REFERENCE_RESOURCES",
|
|
scenarioId: scenario,
|
|
attempt,
|
|
});
|
|
return scenarioResponse(
|
|
"LIST_REFERENCE_RESOURCES",
|
|
scenario,
|
|
payload,
|
|
attempt,
|
|
);
|
|
}),
|
|
http.post(
|
|
`${baseUrl}/api/reference-resources`,
|
|
async ({ request }) => {
|
|
const rawBody = await request.json();
|
|
const body =
|
|
rawBody &&
|
|
typeof rawBody === "object" &&
|
|
!Array.isArray(rawBody)
|
|
? (rawBody as Readonly<Record<string, unknown>>)
|
|
: {};
|
|
options.onCreate?.(body);
|
|
const scenario = scenarioFor("CREATE_REFERENCE_RESOURCE");
|
|
const created = {
|
|
id: "reference-created",
|
|
name: String(body.name ?? "Created"),
|
|
createdAt: "2026-07-26T00:00:00.000Z",
|
|
};
|
|
if (scenario === "success") resources.push(created);
|
|
const attempt = nextAttempt("CREATE_REFERENCE_RESOURCE");
|
|
options.onAttempt?.({
|
|
operationId: "CREATE_REFERENCE_RESOURCE",
|
|
scenarioId: scenario,
|
|
attempt,
|
|
});
|
|
return scenarioResponse(
|
|
"CREATE_REFERENCE_RESOURCE",
|
|
scenario,
|
|
created,
|
|
attempt,
|
|
);
|
|
},
|
|
),
|
|
http.get(
|
|
`${baseUrl}/api/reference-resources/:resourceId`,
|
|
({ params }) => {
|
|
const scenario = scenarioFor("GET_REFERENCE_RESOURCE");
|
|
const resource =
|
|
resources.find((entry) => entry.id === params.resourceId) ??
|
|
DEFAULT_RESOURCE;
|
|
const attempt = nextAttempt("GET_REFERENCE_RESOURCE");
|
|
options.onAttempt?.({
|
|
operationId: "GET_REFERENCE_RESOURCE",
|
|
scenarioId: scenario,
|
|
attempt,
|
|
});
|
|
return scenarioResponse(
|
|
"GET_REFERENCE_RESOURCE",
|
|
scenario,
|
|
resource,
|
|
attempt,
|
|
);
|
|
},
|
|
),
|
|
] as const;
|
|
}
|