From e9db4ca24d77ddd49844e96417c012ab4ea954f9 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 20:16:32 +0530 Subject: [PATCH] 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 --- CHANGELOG.md | 13 ++++ packages/core/test/endpoint-schema.test.ts | 83 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3e7460..c84b1cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router + (which calls handlers as `handler(ctx)`, with no second argument) actually receive their + request input: it now parses query parameters for GET/HEAD and the JSON body otherwise + when no input is passed explicitly. Previously such endpoints silently validated + `undefined`, so an `input` schema with only optional fields passed vacuously regardless of + what was sent. **Behavior change for downstream apps:** a request that previously passed + vacuous validation on a `defineEndpoint` route can now legitimately fail (400 + `VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second + argument (e.g. from a unit test or an internal caller) is unaffected and still takes + priority over reading the request. + ## 0.8.8 - Added the framework request context to `.wrn` language-server type environments. diff --git a/packages/core/test/endpoint-schema.test.ts b/packages/core/test/endpoint-schema.test.ts index a3eeb05a..3735ffc3 100644 --- a/packages/core/test/endpoint-schema.test.ts +++ b/packages/core/test/endpoint-schema.test.ts @@ -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" } }); +});