diff --git a/surfsense_web/lib/playground/field-errors.test.ts b/surfsense_web/lib/playground/field-errors.test.ts new file mode 100644 index 000000000..273cf1cc3 --- /dev/null +++ b/surfsense_web/lib/playground/field-errors.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { AppError } from "@/lib/error"; +import { fieldErrorsFromError } from "./field-errors"; + +// Run with: pnpm exec tsx --test lib/playground/field-errors.test.ts + +function validationError(fields: { loc: string[]; msg: string }[]): AppError { + const error = new AppError("Validation failed", 422, "Unprocessable Entity", "VALIDATION_ERROR"); + error.fields = fields; + return error; +} + +test("maps a body field failure to its top-level field name", () => { + const error = validationError([ + { loc: ["body", "startUrls", "0"], msg: "must be a valid http(s) URL" }, + ]); + assert.deepEqual(fieldErrorsFromError(error), { + startUrls: "must be a valid http(s) URL", + }); +}); + +test("keeps the first failure per field", () => { + const error = validationError([ + { loc: ["body", "urls", "0"], msg: "first" }, + { loc: ["body", "urls", "3"], msg: "second" }, + ]); + assert.deepEqual(fieldErrorsFromError(error), { urls: "first" }); +}); + +test("returns nothing for non-AppError or errors without fields", () => { + assert.deepEqual(fieldErrorsFromError(new Error("boom")), {}); + assert.deepEqual(fieldErrorsFromError(new AppError("no fields", 500)), {}); +}); diff --git a/surfsense_web/lib/playground/field-errors.ts b/surfsense_web/lib/playground/field-errors.ts new file mode 100644 index 000000000..668d2d9c1 --- /dev/null +++ b/surfsense_web/lib/playground/field-errors.ts @@ -0,0 +1,18 @@ +import { AppError } from "@/lib/error"; + +/** Map a 422 response's field failures to ``{ fieldName: message }`` for the form. */ +export function fieldErrorsFromError(error: unknown): Record { + if (!(error instanceof AppError) || !error.fields) return {}; + const errors: Record = {}; + for (const { loc, msg } of error.fields) { + const name = topLevelField(loc); + if (name && !errors[name]) errors[name] = msg; + } + return errors; +} + +/** The input field a location path points at, dropping the ``body`` request root. */ +function topLevelField(loc: string[]): string | undefined { + const path = loc[0] === "body" ? loc.slice(1) : loc; + return path[0]; +}