From 1067ae4fa65dec6f7b97483fd92d648f7520f93c Mon Sep 17 00:00:00 2001 From: Michael Chernigin Date: Tue, 14 Apr 2026 11:51:52 +0400 Subject: [PATCH] refactor: deduplicate answer validation --- components/public-form-runner.tsx | 180 +++++------------------ lib/answer-validation.test.ts | 197 +++++++++++++++++++++++++ lib/answer-validation.ts | 230 ++++++++++++++++++++++++++++++ lib/forms.ts | 204 +++++--------------------- 4 files changed, 502 insertions(+), 309 deletions(-) create mode 100644 lib/answer-validation.test.ts create mode 100644 lib/answer-validation.ts diff --git a/components/public-form-runner.tsx b/components/public-form-runner.tsx index 04b536d..a9436d6 100644 --- a/components/public-form-runner.tsx +++ b/components/public-form-runner.tsx @@ -29,10 +29,6 @@ import { ToastViewport, type ToastData } from "@/components/ui/toast"; import { Textarea } from "@/components/ui/textarea"; import { AGREEMENT_ANSWER_VALUES, - getTextValidationPattern, - isValidDateAnswer, - isValidLinkAnswer, - parseNumericAnswer, type ChoiceBlockConfig, type LinkBlockConfig, type LongTextBlockConfig, @@ -40,6 +36,7 @@ import { type ShortTextBlockConfig, type TextBlockConfig, } from "@/lib/blocks"; +import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; import { resolveNextBlockId } from "@/lib/branching"; import { isLegacyDefaultCompletionMessage, @@ -242,172 +239,67 @@ export function PublicFormRunner({ form }: { form: PublicForm }) { return true; } - const value = answerSet[currentBlock.id]; - - if ( - currentBlock.type === "SHORT_TEXT" || - currentBlock.type === "LONG_TEXT" - ) { - const textValue = typeof value === "string" ? value.trim() : ""; - - if (!textValue) { - if (!currentBlock.required) { - return true; - } - - showToast(t("publicRunner.answerRequired"), "error"); - return false; - } - - const validationRegex = getTextValidationPattern( - currentBlock.config as ShortTextBlockConfig | LongTextBlockConfig, - ); - - if (validationRegex && !new RegExp(validationRegex).test(textValue)) { - showToast(t("publicRunner.invalidTextFormat"), "error"); - return false; - } + const result = validateAndNormalizeAnswer( + currentBlock, + answerSet[currentBlock.id], + ); + if (result.ok) { return true; } - if (currentBlock.type === "SINGLE_CHOICE") { - if (typeof value === "string" && value.trim()) { - return true; - } - - if (currentBlock.required) { + switch (result.error.code) { + case "answer_required": + showToast(t("publicRunner.answerRequired"), "error"); + return false; + case "single_choice_required": showToast(t("publicRunner.singleChoiceRequired"), "error"); return false; - } - - return true; - } - - if (currentBlock.type === "MULTIPLE_CHOICE") { - if (Array.isArray(value) && value.length > 0) { - return true; - } - - if (currentBlock.required) { + case "multi_choice_required": showToast(t("publicRunner.multiChoiceRequired"), "error"); return false; - } - - return true; - } - - if (currentBlock.type === "NUMBER") { - const textValue = typeof value === "string" ? value.trim() : ""; - const config = currentBlock.config as NumberBlockConfig; - - if (!textValue) { - if (!currentBlock.required) { - return true; - } - - showToast(t("publicRunner.answerRequired"), "error"); + case "invalid_option": + showToast( + t( + currentBlock.type === "MULTIPLE_CHOICE" + ? "publicRunner.multiChoiceRequired" + : "publicRunner.singleChoiceRequired", + ), + "error", + ); return false; - } - - const numericValue = parseNumericAnswer(textValue); - - if (numericValue === null) { + case "agreement_required": + case "invalid_agreement": + showToast(t("publicRunner.agreementRequired"), "error"); + return false; + case "invalid_text_format": + showToast(t("publicRunner.invalidTextFormat"), "error"); + return false; + case "invalid_number": showToast(t("publicRunner.invalidNumber"), "error"); return false; - } - - if (!config.allowFloat && !Number.isInteger(numericValue)) { + case "whole_number_required": showToast(t("publicRunner.wholeNumberRequired"), "error"); return false; - } - - if (config.min !== null && numericValue < config.min) { + case "number_below_min": showToast( - t("publicRunner.numberAtLeast", { min: config.min }), + t("publicRunner.numberAtLeast", { min: result.error.min }), "error", ); return false; - } - - if (config.max !== null && numericValue > config.max) { + case "number_above_max": showToast( - t("publicRunner.numberAtMost", { max: config.max }), + t("publicRunner.numberAtMost", { max: result.error.max }), "error", ); return false; - } - - return true; - } - - if (currentBlock.type === "LINK") { - const textValue = typeof value === "string" ? value.trim() : ""; - - if (!textValue) { - if (!currentBlock.required) { - return true; - } - - showToast(t("publicRunner.answerRequired"), "error"); - return false; - } - - if (!isValidLinkAnswer(textValue)) { + case "invalid_link": showToast(t("publicRunner.invalidLink"), "error"); return false; - } - - return true; - } - - if (currentBlock.type === "DATE") { - const textValue = typeof value === "string" ? value.trim() : ""; - - if (!textValue) { - if (!currentBlock.required) { - return true; - } - - showToast(t("publicRunner.answerRequired"), "error"); - return false; - } - - if (!isValidDateAnswer(textValue)) { + case "invalid_date": showToast(t("publicRunner.invalidDate"), "error"); return false; - } - - return true; } - - if (currentBlock.type === "AGREEMENT") { - const textValue = typeof value === "string" ? value.trim() : ""; - - if (!textValue) { - if (!currentBlock.required) { - return true; - } - - showToast(t("publicRunner.agreementRequired"), "error"); - return false; - } - - if ( - currentBlock.required && - textValue !== AGREEMENT_ANSWER_VALUES.AGREED - ) { - showToast(t("publicRunner.agreementRequired"), "error"); - return false; - } - - return ( - textValue === AGREEMENT_ANSWER_VALUES.AGREED || - textValue === AGREEMENT_ANSWER_VALUES.NOT_AGREED - ); - } - - return true; }, [answers, currentBlock, showToast, t], ); diff --git a/lib/answer-validation.test.ts b/lib/answer-validation.test.ts new file mode 100644 index 0000000..a524039 --- /dev/null +++ b/lib/answer-validation.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test"; + +import type { SerializedBlock } from "@/lib/blocks"; +import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; + +function createBlock( + overrides: Partial & Pick, +): SerializedBlock { + const now = new Date("2026-04-14T00:00:00.000Z"); + + return { + id: overrides.id, + formId: "form-1", + type: overrides.type, + title: overrides.title ?? overrides.id, + description: overrides.description ?? "", + required: overrides.required ?? false, + position: overrides.position ?? 0, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + config: + overrides.config ?? + (overrides.type === "TEXT" + ? { body: "Intro" } + : overrides.type === "SINGLE_CHOICE" + ? { + options: ["yes", "no"], + branchRules: [], + defaultNextBlockId: null, + } + : overrides.type === "MULTIPLE_CHOICE" + ? { + options: ["red", "blue", "green"], + branchRules: [], + defaultNextBlockId: null, + } + : overrides.type === "NUMBER" + ? { + placeholder: "", + allowFloat: false, + min: null, + max: null, + branchRules: [], + defaultNextBlockId: null, + } + : overrides.type === "AGREEMENT" + ? { + label: "I agree", + branchRules: [], + defaultNextBlockId: null, + } + : { + placeholder: "", + validationRegex: null, + branchRules: [], + defaultNextBlockId: null, + }), + }; +} + +describe("validateAndNormalizeAnswer", () => { + test("normalizes valid text answers", () => { + const block = createBlock({ + id: "name", + type: "SHORT_TEXT", + required: true, + config: { + placeholder: "", + validationRegex: null, + branchRules: [], + defaultNextBlockId: null, + }, + }); + + const result = validateAndNormalizeAnswer(block, " Ada "); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value).toBe("Ada"); + }); + + test("rejects text that does not match regex", () => { + const block = createBlock({ + id: "code", + type: "SHORT_TEXT", + required: true, + config: { + placeholder: "", + validationRegex: "^[A-Z]+$", + branchRules: [], + defaultNextBlockId: null, + }, + }); + + const result = validateAndNormalizeAnswer(block, "abc"); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("invalid_text_format"); + }); + + test("rejects invalid single-choice options", () => { + const block = createBlock({ id: "choice", type: "SINGLE_CHOICE" }); + + const result = validateAndNormalizeAnswer(block, "maybe"); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("invalid_option"); + }); + + test("deduplicates multiple-choice values", () => { + const block = createBlock({ id: "colors", type: "MULTIPLE_CHOICE" }); + + const result = validateAndNormalizeAnswer(block, ["red", "blue", "red"]); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(JSON.stringify(result.value)).toBe(JSON.stringify(["red", "blue"])); + }); + + test("normalizes valid links", () => { + const block = createBlock({ + id: "site", + type: "LINK", + required: true, + config: { + placeholder: "", + branchRules: [], + defaultNextBlockId: null, + }, + }); + + const result = validateAndNormalizeAnswer(block, "https://example.com"); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value).toBe("https://example.com/"); + }); + + test("enforces required agreement", () => { + const block = createBlock({ + id: "terms", + type: "AGREEMENT", + required: true, + }); + + const result = validateAndNormalizeAnswer(block, "not_agreed"); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("agreement_required"); + }); + + test("validates number limits", () => { + const block = createBlock({ + id: "age", + type: "NUMBER", + required: true, + config: { + placeholder: "", + allowFloat: false, + min: 18, + max: 99, + branchRules: [], + defaultNextBlockId: null, + }, + }); + + const result = validateAndNormalizeAnswer(block, "17"); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("number_below_min"); + }); +}); diff --git a/lib/answer-validation.ts b/lib/answer-validation.ts new file mode 100644 index 0000000..16e05ae --- /dev/null +++ b/lib/answer-validation.ts @@ -0,0 +1,230 @@ +import { + AGREEMENT_ANSWER_VALUES, + isAgreementAnswerValue, + isQuestionBlock, + isTextAnswerBlock, + isValidDateAnswer, + isValidLinkAnswer, + parseNumericAnswer, + type ChoiceBlockConfig, + type NumberBlockConfig, + type SerializedBlock, + type TextAnswerBlockConfig, +} from "@/lib/blocks"; +import type { AnswerValue } from "@/lib/form-types"; + +export type AnswerValidationError = + | { + code: "answer_required"; + } + | { + code: "single_choice_required"; + } + | { + code: "multi_choice_required"; + } + | { + code: "agreement_required"; + } + | { + code: "invalid_text_format"; + } + | { + code: "invalid_option"; + } + | { + code: "invalid_number"; + } + | { + code: "whole_number_required"; + } + | { + code: "number_below_min"; + min: number; + } + | { + code: "number_above_max"; + max: number; + } + | { + code: "invalid_link"; + } + | { + code: "invalid_date"; + } + | { + code: "invalid_agreement"; + }; + +export type AnswerValidationResult = + | { + ok: true; + value: AnswerValue | undefined; + } + | { + ok: false; + error: AnswerValidationError; + }; + +export function validateAndNormalizeAnswer( + block: SerializedBlock, + rawValue: unknown, +): AnswerValidationResult { + if (!isQuestionBlock(block.type)) { + return { ok: true, value: undefined }; + } + + if (isTextAnswerBlock(block.type)) { + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + const validationRegex = (block.config as TextAnswerBlockConfig) + .validationRegex; + + if (!value) { + return block.required + ? { ok: false, error: { code: "answer_required" } } + : { ok: true, value: undefined }; + } + + if (validationRegex && !new RegExp(validationRegex).test(value)) { + return { ok: false, error: { code: "invalid_text_format" } }; + } + + return { ok: true, value }; + } + + if (block.type === "SINGLE_CHOICE") { + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + const options = (block.config as ChoiceBlockConfig).options; + + if (!value) { + return block.required + ? { ok: false, error: { code: "single_choice_required" } } + : { ok: true, value: undefined }; + } + + if (!options.includes(value)) { + return { ok: false, error: { code: "invalid_option" } }; + } + + return { ok: true, value }; + } + + if (block.type === "MULTIPLE_CHOICE") { + const values = Array.isArray(rawValue) + ? rawValue.filter((value): value is string => typeof value === "string") + : []; + const options = (block.config as ChoiceBlockConfig).options; + const uniqueValues = [ + ...new Set(values.map((value) => value.trim()).filter(Boolean)), + ]; + + if (!uniqueValues.length) { + return block.required + ? { ok: false, error: { code: "multi_choice_required" } } + : { ok: true, value: undefined }; + } + + if (!uniqueValues.every((value) => options.includes(value))) { + return { ok: false, error: { code: "invalid_option" } }; + } + + return { ok: true, value: uniqueValues }; + } + + if (block.type === "NUMBER") { + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + const config = block.config as NumberBlockConfig; + + if (!value) { + return block.required + ? { ok: false, error: { code: "answer_required" } } + : { ok: true, value: undefined }; + } + + const numericValue = parseNumericAnswer(value); + + if (numericValue === null) { + return { ok: false, error: { code: "invalid_number" } }; + } + + if (!config.allowFloat && !Number.isInteger(numericValue)) { + return { ok: false, error: { code: "whole_number_required" } }; + } + + if (config.min !== null && numericValue < config.min) { + return { + ok: false, + error: { + code: "number_below_min", + min: config.min, + }, + }; + } + + if (config.max !== null && numericValue > config.max) { + return { + ok: false, + error: { + code: "number_above_max", + max: config.max, + }, + }; + } + + return { ok: true, value: String(numericValue) }; + } + + if (block.type === "LINK") { + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + + if (!value) { + return block.required + ? { ok: false, error: { code: "answer_required" } } + : { ok: true, value: undefined }; + } + + if (!isValidLinkAnswer(value)) { + return { ok: false, error: { code: "invalid_link" } }; + } + + return { ok: true, value: new URL(value).toString() }; + } + + if (block.type === "DATE") { + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + + if (!value) { + return block.required + ? { ok: false, error: { code: "answer_required" } } + : { ok: true, value: undefined }; + } + + if (!isValidDateAnswer(value)) { + return { ok: false, error: { code: "invalid_date" } }; + } + + return { ok: true, value }; + } + + if (block.type === "AGREEMENT") { + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + + if (!value) { + return block.required + ? { ok: false, error: { code: "agreement_required" } } + : { ok: true, value: undefined }; + } + + if (!isAgreementAnswerValue(value)) { + return { ok: false, error: { code: "invalid_agreement" } }; + } + + if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) { + return { ok: false, error: { code: "agreement_required" } }; + } + + return { ok: true, value }; + } + + return { ok: true, value: undefined }; +} diff --git a/lib/forms.ts b/lib/forms.ts index 1fe4b58..6f15d8c 100644 --- a/lib/forms.ts +++ b/lib/forms.ts @@ -12,26 +12,20 @@ import { AGREEMENT_ANSWER_VALUES, blockTypeSchema, getDefaultBlockConfig, - isAgreementAnswerValue, isChoiceBlock, isQuestionBlock, - isTextAnswerBlock, - isValidDateAnswer, - isValidLinkAnswer, parseBlockConfig, - parseNumericAnswer, serializeBlock, type BlockConfig, type ChoiceBlockConfig, - type NumberBlockConfig, type SerializedBlock, - type TextAnswerBlockConfig, } from "@/lib/blocks"; import { getBranchValidationIssueI18n, validateBranchingGraph, resolveNextBlockId, } from "@/lib/branching"; +import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; import { db } from "@/lib/db"; import { AppError } from "@/lib/errors"; import { getLocalizedCompletionDefaults } from "@/lib/form-defaults"; @@ -398,209 +392,89 @@ function normalizeAnswer( block: SerializedBlock, rawValue: unknown, ): string | string[] | undefined { - const config = block.config; const questionLabel = getQuestionLabel(block); + const result = validateAndNormalizeAnswer(block, rawValue); - if (!isQuestionBlock(block.type)) { - return undefined; + if (result.ok) { + return result.value; } - if (isTextAnswerBlock(block.type)) { - const value = typeof rawValue === "string" ? rawValue.trim() : ""; - const validationRegex = (config as TextAnswerBlockConfig).validationRegex; + switch (result.error.code) { + case "answer_required": + if (block.type === FORM_BLOCK_TYPES.NUMBER) { + throw new AppError( + `Please enter a number for “${questionLabel}”.`, + 422, + ); + } - if (!value) { - if (block.required) { - throw new AppError(`Please answer “${questionLabel}”.`, 422); + if (block.type === FORM_BLOCK_TYPES.LINK) { + throw new AppError(`Please enter a link for “${questionLabel}”.`, 422); } - return undefined; - } + if (block.type === FORM_BLOCK_TYPES.DATE) { + throw new AppError(`Please enter a date for “${questionLabel}”.`, 422); + } - if (validationRegex && !new RegExp(validationRegex).test(value)) { + throw new AppError(`Please answer “${questionLabel}”.`, 422); + case "single_choice_required": throw new AppError( - `Please use a valid format for “${questionLabel}”.`, + `Please choose an option for “${questionLabel}”.`, 422, ); - } - - return value; - } - - if (block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE) { - const value = typeof rawValue === "string" ? rawValue.trim() : ""; - const options = (config as ChoiceBlockConfig).options; - - if (!value) { - if (block.required) { - throw new AppError( - `Please choose an option for “${questionLabel}”.`, - 422, - ); - } - - return undefined; - } - - if (!options.includes(value)) { + case "multi_choice_required": throw new AppError( - `Invalid option submitted for “${questionLabel}”.`, + `Please choose at least one option for “${questionLabel}”.`, 422, ); - } - - return value; - } - - if (block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) { - const values = Array.isArray(rawValue) - ? rawValue.filter((value): value is string => typeof value === "string") - : []; - const options = (config as ChoiceBlockConfig).options; - const uniqueValues = [ - ...new Set(values.map((value) => value.trim()).filter(Boolean)), - ]; - - if (!uniqueValues.length) { - if (block.required) { - throw new AppError( - `Please choose at least one option for “${questionLabel}”.`, - 422, - ); - } - - return undefined; - } - - if (!uniqueValues.every((value) => options.includes(value))) { + case "agreement_required": + throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); + case "invalid_text_format": + throw new AppError( + `Please use a valid format for “${questionLabel}”.`, + 422, + ); + case "invalid_option": throw new AppError( `Invalid option submitted for “${questionLabel}”.`, 422, ); - } - - return uniqueValues; - } - - if (block.type === FORM_BLOCK_TYPES.NUMBER) { - const value = typeof rawValue === "string" ? rawValue.trim() : ""; - const numberConfig = config as NumberBlockConfig; - - if (!value) { - if (block.required) { - throw new AppError( - `Please enter a number for “${questionLabel}”.`, - 422, - ); - } - - return undefined; - } - - const numericValue = parseNumericAnswer(value); - - if (numericValue === null) { + case "invalid_number": throw new AppError( `Please enter a valid number for “${questionLabel}”.`, 422, ); - } - - if (!numberConfig.allowFloat && !Number.isInteger(numericValue)) { + case "whole_number_required": throw new AppError( `Please enter a whole number for “${questionLabel}”.`, 422, ); - } - - if (numberConfig.min !== null && numericValue < numberConfig.min) { + case "number_below_min": throw new AppError( - `Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`, + `Please enter a number greater than or equal to ${result.error.min} for “${questionLabel}”.`, 422, ); - } - - if (numberConfig.max !== null && numericValue > numberConfig.max) { + case "number_above_max": throw new AppError( - `Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`, + `Please enter a number less than or equal to ${result.error.max} for “${questionLabel}”.`, 422, ); - } - - return String(numericValue); - } - - if (block.type === FORM_BLOCK_TYPES.LINK) { - const value = typeof rawValue === "string" ? rawValue.trim() : ""; - - if (!value) { - if (block.required) { - throw new AppError(`Please enter a link for “${questionLabel}”.`, 422); - } - - return undefined; - } - - if (!isValidLinkAnswer(value)) { + case "invalid_link": throw new AppError( `Please enter a valid link for “${questionLabel}”.`, 422, ); - } - - return new URL(value).toString(); - } - - if (block.type === FORM_BLOCK_TYPES.DATE) { - const value = typeof rawValue === "string" ? rawValue.trim() : ""; - - if (!value) { - if (block.required) { - throw new AppError(`Please enter a date for “${questionLabel}”.`, 422); - } - - return undefined; - } - - if (!isValidDateAnswer(value)) { + case "invalid_date": throw new AppError( `Please enter a valid date for “${questionLabel}”.`, 422, ); - } - - return value; - } - - if (block.type === FORM_BLOCK_TYPES.AGREEMENT) { - const value = typeof rawValue === "string" ? rawValue.trim() : ""; - - if (!value) { - if (block.required) { - throw new AppError( - `Agreement is required for “${questionLabel}”.`, - 422, - ); - } - - return undefined; - } - - if (!isAgreementAnswerValue(value)) { + case "invalid_agreement": throw new AppError( `Invalid agreement value submitted for “${questionLabel}”.`, 422, ); - } - - if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) { - throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); - } - - return value; } - - return undefined; } function parseSnapshotBlocks(response: ResponseRecord): SerializedBlock[] { -- 2.51.2