API error contract

This commit is contained in:
adilallo
2026-04-22 19:15:04 -06:00
parent 4d066dad0e
commit 5457d3554b
18 changed files with 717 additions and 117 deletions
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
dbUnavailable,
errorJson,
internalError,
notFound,
rateLimited,
serverMisconfigured,
unauthorized,
} from "../../lib/server/responses";
async function readBody(res: Response): Promise<{
error: { code: string; message: string };
details?: unknown;
}> {
return (await res.json()) as {
error: { code: string; message: string };
details?: unknown;
};
}
describe("lib/server/responses", () => {
it("errorJson returns the canonical shape, status, and details", async () => {
const res = errorJson("validation_error", "Bad input", 400, {
details: { field: "email" },
headers: { "x-custom": "1" },
});
expect(res.status).toBe(400);
expect(res.headers.get("x-custom")).toBe("1");
const body = await readBody(res);
expect(body).toEqual({
error: { code: "validation_error", message: "Bad input" },
details: { field: "email" },
});
});
it("errorJson omits details when not provided", async () => {
const res = errorJson("internal_error", "Boom", 500);
const body = await readBody(res);
expect(body.details).toBeUndefined();
expect(body.error).toEqual({ code: "internal_error", message: "Boom" });
});
it("dbUnavailable → 503 db_unavailable", async () => {
const res = dbUnavailable();
expect(res.status).toBe(503);
const body = await readBody(res);
expect(body.error.code).toBe("db_unavailable");
});
it("unauthorized → 401 unauthorized", async () => {
const res = unauthorized();
expect(res.status).toBe(401);
const body = await readBody(res);
expect(body.error).toEqual({
code: "unauthorized",
message: "Unauthorized",
});
});
it("notFound → 404 not_found with optional message", async () => {
const res = notFound("Rule not found");
expect(res.status).toBe(404);
const body = await readBody(res);
expect(body.error).toEqual({
code: "not_found",
message: "Rule not found",
});
});
it("rateLimited → 429 with Retry-After header (seconds, ceil) and details", async () => {
const res = rateLimited(2500);
expect(res.status).toBe(429);
expect(res.headers.get("retry-after")).toBe("3");
const body = await readBody(res);
expect(body.error.code).toBe("rate_limited");
expect(body.details).toEqual({ retryAfterMs: 2500 });
});
it("rateLimited clamps Retry-After to at least 1 second", () => {
const res = rateLimited(0);
expect(res.headers.get("retry-after")).toBe("1");
});
it("serverMisconfigured → 500 server_misconfigured", async () => {
const res = serverMisconfigured();
expect(res.status).toBe(500);
const body = await readBody(res);
expect(body.error.code).toBe("server_misconfigured");
});
it("internalError → 500 internal_error", async () => {
const res = internalError();
expect(res.status).toBe(500);
const body = await readBody(res);
expect(body.error.code).toBe("internal_error");
});
});