Files
cloudflare__vinext/tests/pages-api-route.test.ts
T
Steve Faulkner da04d2062b refactor: extract Pages API route runtime (#683)
* refactor: extract pages api route runtime

* refactor: share Pages ISR cache runtime (#684)

* fix: address Pages API route review feedback

- Replace fragile error.message string matches with typed PagesBodyParseError
  (statusCode: 413) thrown from pages-node-compat.ts and api-handler.ts; the
  existing instanceof catch in handlePagesApiRoute handles both 400 and 413
- Fix res.setHeader('set-cookie') to replace instead of append on repeated
  calls, matching Node.js ServerResponse semantics
- Extract shared getMediaType/isJsonMediaType helpers and the body-parse error
  class into pages-media-type.ts to eliminate drift between api-handler.ts and
  pages-node-compat.ts
- Add unit tests for 404 (null match), missing default export (500), redirect
  (1-arg and 2-arg), writeHead header lowercasing/array joining, setHeader/
  getHeader round-trip, and set-cookie replace-not-append parity

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 09:36:30 +00:00

239 lines
7.7 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
handlePagesApiRoute,
type PagesApiRouteMatch,
} from "../packages/vinext/src/server/pages-api-route.js";
function createMatch(
handler: PagesApiRouteMatch["route"]["module"]["default"],
params: Record<string, string | string[]> = {},
): PagesApiRouteMatch {
return {
params,
route: {
pattern: "/api/test",
module: {
default: handler,
},
},
};
}
describe("pages api route", () => {
it("merges dynamic params with duplicate query-string values", async () => {
const response = await handlePagesApiRoute({
match: createMatch(
(req, res) => {
res.json(req.query);
},
{ id: "123" },
),
request: new Request("https://example.com/api/users/123?tag=a&tag=b"),
url: "/api/users/123?tag=a&tag=b",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
id: "123",
tag: ["a", "b"],
});
});
it("returns 400 with an Invalid JSON statusText for malformed JSON bodies", async () => {
const response = await handlePagesApiRoute({
match: createMatch((req, res) => {
res.json(req.body ?? null);
}),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"message":Invalid"}',
}),
url: "/api/parse",
});
expect(response.status).toBe(400);
expect(response.statusText).toBe("Invalid JSON");
await expect(response.text()).resolves.toBe("Invalid JSON");
});
it("preserves duplicate urlencoded keys and parses empty JSON bodies as {}", async () => {
const parseHandler = (req: { body: unknown }, res: { json: (data: unknown) => void }) => {
res.json(req.body ?? null);
};
const urlencodedResponse = await handlePagesApiRoute({
match: createMatch(parseHandler),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "tag=a&tag=b&tag=c",
}),
url: "/api/parse",
});
await expect(urlencodedResponse.json()).resolves.toEqual({ tag: ["a", "b", "c"] });
const emptyJsonResponse = await handlePagesApiRoute({
match: createMatch(parseHandler),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: { "content-type": "application/json" },
body: "",
}),
url: "/api/parse",
});
await expect(emptyJsonResponse.json()).resolves.toEqual({});
});
it("sends Buffer payloads with octet-stream content-type and content-length", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.send(Buffer.from([1, 2, 3]));
}),
request: new Request("https://example.com/api/send-buffer"),
url: "/api/send-buffer",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/octet-stream");
expect(response.headers.get("content-length")).toBe("3");
expect(Buffer.from(await response.arrayBuffer()).equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("reports thrown handler errors and returns a 500 response", async () => {
const reportRequestError = vi.fn();
const response = await handlePagesApiRoute({
match: createMatch(() => {
throw new Error("boom");
}),
reportRequestError,
request: new Request("https://example.com/api/fail"),
url: "/api/fail",
});
expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe("Internal Server Error");
expect(reportRequestError).toHaveBeenCalledWith(expect.any(Error), "/api/test");
});
it("returns 413 when the API body exceeds the default size limit", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.status(200).json({ ok: true });
}),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: {
"content-length": String(2 * 1024 * 1024),
"content-type": "application/json",
},
body: "{}",
}),
url: "/api/parse",
});
expect(response.status).toBe(413);
await expect(response.text()).resolves.toBe("Request body too large");
});
it("returns 404 when match is null", async () => {
const response = await handlePagesApiRoute({
match: null,
request: new Request("https://example.com/api/not-found"),
url: "/api/not-found",
});
expect(response.status).toBe(404);
await expect(response.text()).resolves.toBe("404 - API route not found");
});
it("returns 500 when the route module has no default export", async () => {
const response = await handlePagesApiRoute({
match: {
params: {},
route: {
pattern: "/api/no-export",
module: {},
},
},
request: new Request("https://example.com/api/no-export"),
url: "/api/no-export",
});
expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe("API route does not export a default function");
});
it("res.redirect() uses 307 by default and 2-arg form uses the given status", async () => {
const defaultRedirectResponse = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.redirect("/new-path");
}),
request: new Request("https://example.com/api/redir"),
url: "/api/redir",
});
expect(defaultRedirectResponse.status).toBe(307);
expect(defaultRedirectResponse.headers.get("location")).toBe("/new-path");
const customRedirectResponse = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.redirect(301, "/permanent");
}),
request: new Request("https://example.com/api/redir"),
url: "/api/redir",
});
expect(customRedirectResponse.status).toBe(301);
expect(customRedirectResponse.headers.get("location")).toBe("/permanent");
});
it("res.writeHead() lowercases header keys and joins array values", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.writeHead(200, { "X-Custom": "value", "X-Multi": ["a", "b"] });
res.end();
}),
request: new Request("https://example.com/api/headers"),
url: "/api/headers",
});
expect(response.status).toBe(200);
expect(response.headers.get("x-custom")).toBe("value");
expect(response.headers.get("x-multi")).toBe("a, b");
});
it("res.setHeader and res.getHeader round-trip correctly", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.setHeader("x-foo", "bar");
const val = res.getHeader("x-foo");
res.json({ val });
}),
request: new Request("https://example.com/api/roundtrip"),
url: "/api/roundtrip",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ val: "bar" });
});
it("res.setHeader replaces set-cookie on repeated calls (Node.js parity)", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.setHeader("set-cookie", "session=abc");
res.setHeader("set-cookie", "session=xyz"); // should replace, not append
res.end();
}),
request: new Request("https://example.com/api/cookie"),
url: "/api/cookie",
});
expect(response.status).toBe(200);
// Only one set-cookie header — the replacement
const cookies = response.headers.getSetCookie();
expect(cookies).toEqual(["session=xyz"]);
});
});