Every existing test in endpoint-schema.test.ts passed rawInput explicitly, so the branch added to endpoint.ts's fix (GET/HEAD query parsing, JSON body parsing, malformed/absent body fallback) was exercised by nothing but a manual curl. Add coverage that calls the endpoint with only a context, matching the real router's calling convention: - GET with query parameters populates input from ctx.url.searchParams. - POST with a JSON body populates input from the parsed body. - POST with a malformed or absent body does not throw; the schema's own validation decides the outcome (asserted on the real response). - An explicit rawInput argument still wins and the request is never read (the body is drained first, so a second .json() call would reject if the endpoint tried to read it again) -- the regression guard for the branch intentionally left untouched. Confirmed the GET and POST-body tests fail against the pre-fix endpoint.ts (input resolves as undefined/null instead of the sent value); the malformed/absent-body test does not distinguish pre- and post-fix, because in that specific edge case both normalize to an effectively empty input -- noted in the report rather than forced. Added a CHANGELOG entry documenting the behavior change for downstream apps: a request that previously passed vacuous validation on a defineEndpoint route can now legitimately fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
4.9 KiB
TypeScript
115 lines
4.9 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { createContext, defineEndpoint } from "../src/index.ts";
|
|
import { v } from "@wrnexus/validation";
|
|
|
|
const user = v.object({ name: v.string().min(2), email: v.string().email() });
|
|
const endpoint = defineEndpoint({
|
|
input: user,
|
|
output: user,
|
|
handler(input) {
|
|
return input;
|
|
},
|
|
});
|
|
|
|
test("typed endpoints unwrap official validation schemas and return bounded validation errors", async () => {
|
|
const request = new Request("https://example.test/api/user");
|
|
const ctx = createContext(request, new URL(request.url));
|
|
const invalid = await endpoint(ctx, { name: "A", email: "bad" });
|
|
expect(invalid.status).toBe(400);
|
|
expect(await invalid.json()).toEqual({
|
|
error: {
|
|
code: "VALIDATION_ERROR",
|
|
message: "Endpoint validation failed.",
|
|
details: {
|
|
name: "Must be at least 2 characters",
|
|
email: "Must be a valid email",
|
|
},
|
|
},
|
|
});
|
|
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
|
|
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
|
|
});
|
|
|
|
// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route
|
|
// handlers as `handler(ctx)` — it never supplies a second argument. Every test above
|
|
// passes rawInput explicitly, so it never exercises that calling convention. These
|
|
// tests call the endpoint with only a context, matching what actually happens in
|
|
// production, to guard against silently validating `undefined` again.
|
|
const search = v.object({ name: v.string().trim().optional() });
|
|
const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({
|
|
input: search,
|
|
handler(input) {
|
|
return { name: input.name ?? null };
|
|
},
|
|
});
|
|
|
|
test("with no second argument, a GET request reads input from the URL's query string", async () => {
|
|
const request = new Request("https://example.test/api/search?name=Ada");
|
|
const ctx = createContext(request, new URL(request.url));
|
|
const response = await searchEndpoint(ctx);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ data: { name: "Ada" } });
|
|
});
|
|
|
|
test("with no second argument, a POST request reads input from the parsed JSON body", async () => {
|
|
const request = new Request("https://example.test/api/search", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ name: "Ada" }),
|
|
});
|
|
const ctx = createContext(request, new URL(request.url));
|
|
const response = await searchEndpoint(ctx);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ data: { name: "Ada" } });
|
|
});
|
|
|
|
test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => {
|
|
const malformedRequest = new Request("https://example.test/api/search", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: "{not json",
|
|
});
|
|
const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url));
|
|
const malformedResponse = await searchEndpoint(malformedCtx);
|
|
// `name` is optional, so an empty resolved input ({}) still validates and succeeds —
|
|
// the point is that the malformed body did not throw an unhandled parse error.
|
|
expect(malformedResponse.status).toBe(200);
|
|
expect(await malformedResponse.json()).toEqual({ data: { name: null } });
|
|
|
|
const requiredField = v.object({ name: v.string().min(1) });
|
|
const requiredEndpoint = defineEndpoint({
|
|
input: requiredField,
|
|
handler(input) {
|
|
return input;
|
|
},
|
|
});
|
|
const emptyRequest = new Request("https://example.test/api/search", { method: "POST" });
|
|
const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url));
|
|
const emptyResponse = await requiredEndpoint(emptyCtx);
|
|
// With no body at all, resolved input is {} — the schema's own required-field
|
|
// validation is what turns that into a 400, not a thrown parse error.
|
|
expect(emptyResponse.status).toBe(400);
|
|
expect(await emptyResponse.json()).toEqual({
|
|
error: {
|
|
code: "VALIDATION_ERROR",
|
|
message: "Endpoint validation failed.",
|
|
details: { name: "Required" },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("an explicit rawInput argument still wins and the request is never read", async () => {
|
|
// A request whose body has already been consumed: if the endpoint tried to read it
|
|
// again (rather than trusting the explicit rawInput), this would throw.
|
|
const request = new Request("https://example.test/api/search", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ name: "ignored-body" }),
|
|
});
|
|
await request.json(); // drain the body so a second .json() call would reject
|
|
const ctx = createContext(request, new URL(request.url));
|
|
const response = await searchEndpoint(ctx, { name: "Explicit" });
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ data: { name: "Explicit" } });
|
|
});
|