Files
WRNexusJS/packages/core/test/endpoint-schema.test.ts
Clintchiz 3ae5d7cf97 fix(compiler): stop error {} from swallowing response {} bugs in api blocks
Client codegen chained .then().catch(), so a .catch() after .then()
caught exceptions thrown by the response body too. Switched to the
two-argument then(onFulfilled, onRejected) form, whose rejection
handler cannot see errors from the fulfilment handler.

SSR codegen wrapped both the transport call and the response-body eval
in the same try; only __wrnexusCallApi is now inside the try, and
__wrnexusEvalData runs after it, outside.

Also verifies (and locks in with a regression test) that GET query
numbers already coerce correctly through defineEndpoint + checkField,
and documents that in the typed-api-block spec.
2026-08-19 21:11:26 +05:30

137 lines
6.0 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" },
},
});
});
// 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" } });
});