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" }, }, }); }); // GET query strings travel as text (`URLSearchParams` values are always // strings), so a `v.number()` field must come back as a real number, not the // string the wire actually carried, or a page declaring `age?: number` on a // GET api block would be lying about the type. checkField in // @wrnexus/validation coerces via Number(pre) for both optional and required // number fields (see packages/validation/src/index.ts); this locks that in // end-to-end through defineEndpoint's own GET query-string resolution path. test("a GET request coerces a v.number() query param to an actual number", async () => { const ageSchema = v.object({ age: v.number() }); const ageEndpoint = defineEndpoint<{ age: number }, { age: number; typeofAge: string }>({ input: ageSchema, handler(input) { return { age: input.age, typeofAge: typeof input.age }; }, }); const request = new Request("https://example.test/api/age?age=30"); const ctx = createContext(request, new URL(request.url)); const response = await ageEndpoint(ctx); expect(response.status).toBe(200); expect(await response.json()).toEqual({ data: { age: 30, typeofAge: "number" } }); }); 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" } }); });