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.
137 lines
6.0 KiB
TypeScript
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" } });
|
|
});
|