alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
339 lines (303 loc) • 9.6 kB
text/typescript
import { Alepha, z } from "alepha";
import { describe, it } from "vitest";
import { $route, ServerProvider } from "../index.ts";
class TestApp {
createUser = $route({
method: "POST",
path: "/users",
schema: {
body: z.object({
name: z.text(),
age: z.integer(),
}),
},
handler: () => {},
});
getUser = $route({
method: "GET",
path: "/users/:id",
schema: {
params: z.object({
id: z.integer(),
}),
},
handler: () => {},
});
searchUsers = $route({
method: "GET",
path: "/users",
schema: {
query: z.object({
limit: z.integer().min(1).max(100),
}),
},
handler: () => {},
});
protectedRoute = $route({
method: "GET",
path: "/protected",
schema: {
headers: z.object({
"x-api-version": z.integer(),
}),
},
handler: () => {},
});
}
const fetchAndStop = async (
alepha: Alepha,
build: (hostname: string) => { url: string; init?: RequestInit },
) => {
await alepha.start();
const hostname = alepha.inject(ServerProvider).hostname;
const { url, init } = build(hostname);
const response = await fetch(url, init);
const json = await response.json();
await alepha.stop();
return { response, json };
};
describe("ServerRouterProvider - request validation error", () => {
describe("body validation", () => {
it("should expose a wrong-type rejection (expected integer, got string)", async ({
expect,
}) => {
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(TestApp);
const { response, json } = await fetchAndStop(alepha, (host) => ({
url: `${host}/users`,
init: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "John", age: "not-a-number" }),
},
}));
expect(response.status).toBe(400);
expect(json.error).toBe("ValidationError");
expect(json.message).toMatch(/^Invalid request body:/);
expect(json.message.toLowerCase()).toMatch(/integer|expected/);
// path locates the offending field
expect(json.details).toBe("/age");
});
it("should expose a missing-field rejection (required property `age`)", async ({
expect,
}) => {
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(TestApp);
const { response, json } = await fetchAndStop(alepha, (host) => ({
url: `${host}/users`,
init: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "John" }),
},
}));
expect(response.status).toBe(400);
expect(json.error).toBe("ValidationError");
expect(json.message).toMatch(/^Invalid request body:/);
// message must name the missing field
expect(json.message).toContain("age");
expect(json.message.toLowerCase()).toMatch(/required/);
});
it("should behave identically in development mode", async ({ expect }) => {
const alepha = Alepha.create().with(TestApp);
const { response, json } = await fetchAndStop(alepha, (host) => ({
url: `${host}/users`,
init: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "John" }),
},
}));
expect(response.status).toBe(400);
expect(json.error).toBe("ValidationError");
expect(json.message).toMatch(/^Invalid request body:/);
expect(json.message.toLowerCase()).toMatch(/age|required/);
});
});
describe("params validation", () => {
it("should expose the rejection reason in production", async ({
expect,
}) => {
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(TestApp);
const { response, json } = await fetchAndStop(alepha, (host) => ({
url: `${host}/users/not-a-number`,
}));
expect(response.status).toBe(400);
expect(json.error).toBe("ValidationError");
expect(json.message).toMatch(/^Invalid request params:/);
expect(json.message.toLowerCase()).toMatch(/integer|number|expected/);
});
});
describe("query validation", () => {
it("should expose the rejection reason in production", async ({
expect,
}) => {
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(TestApp);
const { response, json } = await fetchAndStop(alepha, (host) => ({
url: `${host}/users?limit=9999`,
}));
expect(response.status).toBe(400);
expect(json.error).toBe("ValidationError");
expect(json.message).toMatch(/^Invalid request query:/);
expect(json.message.toLowerCase()).toMatch(/maximum|less|<=|100/);
});
});
describe("header validation", () => {
it("should expose the rejection reason in production", async ({
expect,
}) => {
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(TestApp);
const { response, json } = await fetchAndStop(alepha, (host) => ({
url: `${host}/protected`,
// omit required header
}));
expect(response.status).toBe(400);
expect(json.error).toBe("ValidationError");
expect(json.message).toMatch(/^Invalid request header:/);
expect(json.message.toLowerCase()).toMatch(/x-api-version|required/);
});
it("should coerce string header values to declared schema types", async ({
expect,
}) => {
const seen: { version?: unknown } = {};
class CoerceApp {
captured = $route({
method: "GET",
path: "/coerce",
schema: {
headers: z.object({
"x-api-version": z.integer(),
}),
},
handler: ({ headers }) => {
seen.version = headers["x-api-version"];
},
});
}
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(CoerceApp);
await alepha.start();
const host = alepha.inject(ServerProvider).hostname;
const response = await fetch(`${host}/coerce`, {
headers: { "x-api-version": "42" },
});
await alepha.stop();
expect([200, 204]).toContain(response.status);
expect(seen.version).toBe(42);
expect(typeof seen.version).toBe("number");
});
it("should preserve undeclared headers (auth, user-agent, ...)", async ({
expect,
}) => {
const seen: { authorization?: string; userAgent?: string } = {};
class PreserveApp {
check = $route({
method: "GET",
path: "/preserve",
schema: {
headers: z.object({
"x-api-version": z.integer(),
}),
},
handler: ({ headers }) => {
const all = headers as unknown as Record<string, string>;
seen.authorization = all.authorization;
seen.userAgent = all["user-agent"];
},
});
}
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(PreserveApp);
await alepha.start();
const host = alepha.inject(ServerProvider).hostname;
const response = await fetch(`${host}/preserve`, {
headers: {
"x-api-version": "1",
authorization: "Bearer abc.def.ghi",
"user-agent": "MyClient/1.0",
},
});
await alepha.stop();
expect([200, 204]).toContain(response.status);
expect(seen.authorization).toBe("Bearer abc.def.ghi");
expect(seen.userAgent).toBe("MyClient/1.0");
});
it("should accept schema keys regardless of case (lowercase normalization)", async ({
expect,
}) => {
const seen: { version?: unknown } = {};
class CaseApp {
// Schema declares keys with mixed case — Node lowercases incoming
// header names, so the framework must lowercase schema keys when
// matching values.
check = $route({
method: "GET",
path: "/case",
schema: {
headers: z.object({
"X-Api-Version": z.integer(),
}),
},
handler: ({ headers }) => {
seen.version = (headers as Record<string, unknown>)[
"x-api-version"
];
},
});
}
const alepha = Alepha.create({
env: {
NODE_ENV: "production",
SERVER_PORT: 0,
APP_SECRET: "test-secret",
},
}).with(CaseApp);
await alepha.start();
const host = alepha.inject(ServerProvider).hostname;
const response = await fetch(`${host}/case`, {
headers: { "x-api-version": "7" },
});
await alepha.stop();
expect([200, 204]).toContain(response.status);
expect(seen.version).toBe(7);
});
});
});