API error contract
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const errorMock = vi.fn();
|
||||
vi.mock("../../lib/logger", () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (...args: unknown[]) => errorMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiRoute } from "../../lib/server/apiRoute";
|
||||
import { REQUEST_ID_HEADER } from "../../lib/server/requestId";
|
||||
|
||||
afterEach(() => {
|
||||
errorMock.mockReset();
|
||||
});
|
||||
|
||||
function makeReq(headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest("https://x.test/api/x", { headers });
|
||||
}
|
||||
|
||||
describe("lib/server/apiRoute", () => {
|
||||
it("attaches a generated x-request-id to a successful response", async () => {
|
||||
const handler = apiRoute("test.scope", () =>
|
||||
NextResponse.json({ ok: true }),
|
||||
);
|
||||
const res = await handler(makeReq(), undefined);
|
||||
expect(res.status).toBe(200);
|
||||
const id = res.headers.get(REQUEST_ID_HEADER);
|
||||
expect(id).toBeTruthy();
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an incoming x-request-id and exposes it to the handler", async () => {
|
||||
const incoming = "req_forwarded-1";
|
||||
let seen: string | undefined;
|
||||
const handler = apiRoute("test.scope", (_req, _ctx, { requestId }) => {
|
||||
seen = requestId;
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
const res = await handler(
|
||||
makeReq({ [REQUEST_ID_HEADER]: incoming }),
|
||||
undefined,
|
||||
);
|
||||
expect(seen).toBe(incoming);
|
||||
expect(res.headers.get(REQUEST_ID_HEADER)).toBe(incoming);
|
||||
});
|
||||
|
||||
it("returns canonical 500 + logs when the handler throws", async () => {
|
||||
const handler = apiRoute("test.scope", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const res = await handler(makeReq(), undefined);
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as {
|
||||
error: { code: string; message: string };
|
||||
};
|
||||
expect(body.error.code).toBe("internal_error");
|
||||
expect(res.headers.get(REQUEST_ID_HEADER)).toBeTruthy();
|
||||
|
||||
expect(errorMock).toHaveBeenCalledTimes(1);
|
||||
const payload = errorMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.scope).toBe("test.scope");
|
||||
expect(payload.requestId).toBe(res.headers.get(REQUEST_ID_HEADER));
|
||||
expect(payload.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("passes the route ctx through to the handler", async () => {
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
const handler = apiRoute<Ctx>("test.scope", async (_req, ctx) => {
|
||||
const { id } = await ctx.params;
|
||||
return NextResponse.json({ id });
|
||||
});
|
||||
const res = await handler(makeReq(), {
|
||||
params: Promise.resolve({ id: "abc" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { id: string };
|
||||
expect(body.id).toBe("abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
const findUniqueMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
ruleDraft: {
|
||||
findUnique: (...args: unknown[]) => findUniqueMock(...args),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/drafts/me/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
findUniqueMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/drafts/me", () => {
|
||||
it("returns 503 with the canonical shape when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/drafts/me"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as {
|
||||
error: { code: string; message: string };
|
||||
};
|
||||
expect(body.error.code).toBe("db_unavailable");
|
||||
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 401 unauthorized with the canonical shape when no session user", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/drafts/me"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as {
|
||||
error: { code: string; message: string };
|
||||
};
|
||||
expect(body.error).toEqual({
|
||||
code: "unauthorized",
|
||||
message: "Unauthorized",
|
||||
});
|
||||
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||
expect(findUniqueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards an incoming x-request-id on the response", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/drafts/me", {
|
||||
headers: { "x-request-id": "req_drafts-1" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.headers.get("x-request-id")).toBe("req_drafts-1");
|
||||
});
|
||||
|
||||
it("returns the draft when present", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce({
|
||||
id: "u1",
|
||||
email: "x@y.test",
|
||||
});
|
||||
findUniqueMock.mockResolvedValueOnce({
|
||||
payload: { foo: 1 },
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/drafts/me"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
draft: { payload: { foo: number } } | null;
|
||||
};
|
||||
expect(body.draft?.payload).toEqual({ foo: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
getOrCreateRequestId,
|
||||
withRequestId,
|
||||
} from "../../lib/server/requestId";
|
||||
|
||||
function reqWith(headers: Record<string, string>): Request {
|
||||
return new Request("https://x.test/api/x", { headers });
|
||||
}
|
||||
|
||||
describe("lib/server/requestId", () => {
|
||||
it("generates a UUID when no header is present", () => {
|
||||
const id = getOrCreateRequestId(reqWith({}));
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a well-formed incoming x-request-id", () => {
|
||||
const incoming = "req_abc-123.456";
|
||||
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: incoming }));
|
||||
expect(id).toBe(incoming);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from a well-formed id", () => {
|
||||
const id = getOrCreateRequestId(
|
||||
reqWith({ [REQUEST_ID_HEADER]: " abc-123 " }),
|
||||
);
|
||||
expect(id).toBe("abc-123");
|
||||
});
|
||||
|
||||
it("rejects oversized ids and falls back to a UUID", () => {
|
||||
const huge = "a".repeat(200);
|
||||
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: huge }));
|
||||
expect(id).not.toBe(huge);
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects ids with disallowed characters", () => {
|
||||
// Spaces, semicolons, slashes are valid HTTP header bytes but disallowed
|
||||
// by our `[A-Za-z0-9_.-]` pattern.
|
||||
const bad = "abc def;<script>";
|
||||
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: bad }));
|
||||
expect(id).not.toBe(bad);
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("withRequestId attaches the header to a NextResponse", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const out = withRequestId(res, "req_xyz");
|
||||
expect(out).toBe(res);
|
||||
expect(out.headers.get(REQUEST_ID_HEADER)).toBe("req_xyz");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
@@ -29,31 +30,50 @@ beforeEach(() => {
|
||||
describe("GET /api/rules/[id]", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(new Request("https://x.test/api/rules/abc"), makeContext("abc"));
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/rules/abc"),
|
||||
makeContext("abc"),
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(findUniqueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when no published rule matches the id", async () => {
|
||||
it("returns 404 with the canonical error shape when no published rule matches the id", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
findUniqueMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new Request("https://x.test/api/rules/missing"),
|
||||
new NextRequest("https://x.test/api/rules/missing"),
|
||||
makeContext("missing"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||
const body = (await res.json()) as {
|
||||
error: { code: string; message: string };
|
||||
};
|
||||
expect(body.error.code).toBe("not_found");
|
||||
expect(typeof body.error.message).toBe("string");
|
||||
expect(findUniqueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "missing" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an incoming x-request-id on the response", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
findUniqueMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/rules/missing", {
|
||||
headers: { "x-request-id": "req_test-1" },
|
||||
}),
|
||||
makeContext("missing"),
|
||||
);
|
||||
expect(res.headers.get("x-request-id")).toBe("req_test-1");
|
||||
});
|
||||
|
||||
it("returns 404 when the query throws (swallowed by helper)", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
findUniqueMock.mockRejectedValueOnce(new Error("db down"));
|
||||
const res = await GET(
|
||||
new Request("https://x.test/api/rules/broken"),
|
||||
new NextRequest("https://x.test/api/rules/broken"),
|
||||
makeContext("broken"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
@@ -71,7 +91,7 @@ describe("GET /api/rules/[id]", () => {
|
||||
};
|
||||
findUniqueMock.mockResolvedValueOnce(row);
|
||||
const res = await GET(
|
||||
new Request("https://x.test/api/rules/rule-1"),
|
||||
new NextRequest("https://x.test/api/rules/rule-1"),
|
||||
makeContext("rule-1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user