212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { v, invalid, parseBody, checkField, parseEnv, VALIDATE_RUNTIME } from "../src/index.ts";
|
|
|
|
const login = v.object({
|
|
email: v.string().email(),
|
|
password: v.string().min(8, "too short"),
|
|
age: v.number().min(18).optional(),
|
|
});
|
|
|
|
test("parse coerces valid input and reports no errors", () => {
|
|
const r = login.parse({ email: "a@b.com", password: "secretpw", age: "25" });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.value).toEqual({ email: "a@b.com", password: "secretpw", age: 25 });
|
|
expect(r.errors).toEqual({});
|
|
});
|
|
|
|
test("parse reports per-field errors with custom messages", () => {
|
|
const r = login.parse({ email: "nope", password: "x" });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors.email).toBe("Must be a valid email");
|
|
expect(r.errors.password).toBe("too short");
|
|
});
|
|
|
|
test("optional fields may be omitted", () => {
|
|
const r = login.parse({ email: "a@b.com", password: "longenough" });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.value.age).toBeUndefined();
|
|
});
|
|
|
|
test("required (non-optional) empty fields fail", () => {
|
|
const r = login.parse({});
|
|
expect(r.errors.email).toBe("Required");
|
|
expect(r.errors.password).toBe("Required");
|
|
});
|
|
|
|
test("required(message) customizes empty-field errors and overrides optional", () => {
|
|
const schema = v.object({
|
|
email: v.string().required("Enter your email or username"),
|
|
password: v.string().optional().required("Enter your password"),
|
|
accepted: v.boolean().required("Accept the terms to continue"),
|
|
});
|
|
const result = schema.parse({ accepted: false });
|
|
|
|
expect(result.errors).toEqual({
|
|
email: "Enter your email or username",
|
|
password: "Enter your password",
|
|
accepted: "Accept the terms to continue",
|
|
});
|
|
expect(schema.describe().fields.email.requiredMessage).toBe("Enter your email or username");
|
|
});
|
|
|
|
test("browser validation displays the serialized custom required message", () => {
|
|
const schema = v.object({
|
|
email: v.string().required("Enter your email or username"),
|
|
});
|
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
|
win.document.body.innerHTML = `
|
|
<form data-schema="login">
|
|
<input name="email">
|
|
<span data-error="email"></span>
|
|
</form>`;
|
|
win.__wireSchemas = { login: schema.describe() };
|
|
(globalThis as Record<string, unknown>).window = win;
|
|
(globalThis as Record<string, unknown>).document = win.document;
|
|
|
|
try {
|
|
(0, eval)(VALIDATE_RUNTIME);
|
|
const runtime = win.__wireValidate as { init(root: Document): void };
|
|
runtime.init(win.document as unknown as Document);
|
|
win.document.querySelector("input")!.dispatchEvent(new win.Event("blur", { bubbles: true }));
|
|
|
|
expect(win.document.querySelector("[data-error=email]")!.textContent).toBe(
|
|
"Enter your email or username",
|
|
);
|
|
} finally {
|
|
delete (globalThis as Record<string, unknown>).window;
|
|
delete (globalThis as Record<string, unknown>).document;
|
|
}
|
|
});
|
|
|
|
test("describe emits a JSON descriptor", () => {
|
|
const d = login.describe();
|
|
expect(d.type).toBe("object");
|
|
expect(d.fields.email.rules).toContainEqual({ kind: "email" });
|
|
expect(d.fields.age.optional).toBe(true);
|
|
});
|
|
|
|
test("checkField coerces and applies rules", () => {
|
|
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "5").error).toBe(
|
|
"Must be at least 18",
|
|
);
|
|
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "20").value).toBe(20);
|
|
expect(checkField({ type: "string", optional: true, rules: [] }, "").error).toBeNull();
|
|
expect(
|
|
checkField({ type: "string", requiredMessage: "Email is required", rules: [] }, "").error,
|
|
).toBe("Email is required");
|
|
});
|
|
|
|
test("invalid() returns a 400 with errors", async () => {
|
|
const res = invalid({ email: "bad" });
|
|
expect(res.status).toBe(400);
|
|
expect(await res.json()).toEqual({ ok: false, errors: { email: "bad" } });
|
|
});
|
|
|
|
test("parseBody reads JSON and form-encoded bodies", async () => {
|
|
const jsonReq = new Request("http://x", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ email: "a@b.com", password: "longenough" }),
|
|
});
|
|
const j = await parseBody(login, jsonReq);
|
|
expect(j.ok).toBe(true);
|
|
|
|
const formReq = new Request("http://x", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: "email=a@b.com&password=longenough",
|
|
});
|
|
const f = await parseBody(login, formReq);
|
|
expect(f.ok).toBe(true);
|
|
});
|
|
|
|
test("new string rules: url, uuid, date, length, oneOf, trim", () => {
|
|
const s = v.object({
|
|
site: v.string().url(),
|
|
id: v.string().uuid(),
|
|
when: v.string().date(),
|
|
code: v.string().length(4),
|
|
role: v.string().oneOf(["admin", "user"]),
|
|
name: v.string().trim().min(2),
|
|
});
|
|
const ok = s.parse({
|
|
site: "https://x.com/a",
|
|
id: "123e4567-e89b-12d3-a456-426614174000",
|
|
when: "2026-01-01",
|
|
code: "ABCD",
|
|
role: "admin",
|
|
name: " Ada ",
|
|
});
|
|
expect(ok.ok).toBe(true);
|
|
expect(ok.value.name).toBe("Ada"); // trimmed
|
|
|
|
const bad = s.parse({
|
|
site: "not-a-url",
|
|
id: "nope",
|
|
when: "not-a-date",
|
|
code: "AB",
|
|
role: "root",
|
|
name: " a ",
|
|
});
|
|
expect(bad.ok).toBe(false);
|
|
expect(Object.keys(bad.errors).sort()).toEqual(["code", "id", "name", "role", "site", "when"]);
|
|
});
|
|
|
|
test("number rules: positive, oneOf", () => {
|
|
const s = v.object({ qty: v.number().positive(), size: v.number().oneOf([1, 2, 3]) });
|
|
expect(s.parse({ qty: "5", size: "2" }).ok).toBe(true);
|
|
expect(s.parse({ qty: "-1", size: "9" }).ok).toBe(false);
|
|
});
|
|
|
|
test("default() fills absent fields; refine() runs server-side", () => {
|
|
const s = v.object({
|
|
role: v.string().default("user"),
|
|
even: v.number().refine((n) => (n as number) % 2 === 0, "Must be even"),
|
|
});
|
|
const r = s.parse({ even: 4 });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.value.role).toBe("user"); // default applied
|
|
const bad = s.parse({ even: 3 });
|
|
expect(bad.ok).toBe(false);
|
|
expect(bad.errors.even).toBe("Must be even");
|
|
});
|
|
|
|
const envSchema = v.object({
|
|
DATABASE_URL: v.string().min(1),
|
|
PORT: v.number(),
|
|
DEBUG: v.boolean().optional(),
|
|
});
|
|
|
|
test("parseEnv coerces and returns typed values", () => {
|
|
const env = parseEnv<{ DATABASE_URL: string; PORT: number; DEBUG?: boolean }>(envSchema, {
|
|
DATABASE_URL: "sqlite://dev.db",
|
|
PORT: "3000",
|
|
DEBUG: "true",
|
|
});
|
|
expect(env.DATABASE_URL).toBe("sqlite://dev.db");
|
|
expect(env.PORT).toBe(3000);
|
|
expect(env.DEBUG).toBe(true);
|
|
});
|
|
|
|
test("parseEnv throws one readable error listing every missing/invalid var", () => {
|
|
let message = "";
|
|
try {
|
|
parseEnv(envSchema, { PORT: "not-a-number" });
|
|
} catch (err) {
|
|
message = (err as Error).message;
|
|
}
|
|
expect(message).toContain("Invalid environment variables");
|
|
expect(message).toContain("DATABASE_URL");
|
|
expect(message).toContain("PORT");
|
|
});
|
|
|
|
test("parseEnv defaults to reading the ambient environment", () => {
|
|
process.env.WIRE_TEST_ENV_VAR = "present";
|
|
const env = parseEnv<{ WIRE_TEST_ENV_VAR: string }>(
|
|
v.object({ WIRE_TEST_ENV_VAR: v.string().min(1) }),
|
|
);
|
|
expect(env.WIRE_TEST_ENV_VAR).toBe("present");
|
|
delete process.env.WIRE_TEST_ENV_VAR;
|
|
});
|