test(core): cover defineEndpoint's no-second-argument request-parsing path
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>
This commit is contained in:
@@ -29,3 +29,86 @@ test("typed endpoints unwrap official validation schemas and return bounded vali
|
||||
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" } });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user