From 8518c2c1401a41aab3c1c281218c2db1e51a6741 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Mon, 13 Apr 2026 08:37:22 +0330 Subject: [PATCH] refactor(inquiries): Update form validation and error handling - Change required fields in inquiries form to enforce mandatory input - Enhance API error handler to provide user-friendly messages without exposing raw server text - Improve test cases for API error handling to ensure accurate message mapping and validation - Refactor error messages for clarity and consistency across the application --- app/(not-found)/[...not-found]/layout.tsx | 4 +- app/_inquires/form.tsx | 29 +- lib/__tests__/apiErrorHandler.test.ts | 102 ++++-- lib/apiErrorHandler.ts | 360 +++++++++++++++++----- 4 files changed, 380 insertions(+), 115 deletions(-) diff --git a/app/(not-found)/[...not-found]/layout.tsx b/app/(not-found)/[...not-found]/layout.tsx index 24c80c3..2be2a2f 100644 --- a/app/(not-found)/[...not-found]/layout.tsx +++ b/app/(not-found)/[...not-found]/layout.tsx @@ -4,9 +4,9 @@ import Image from "next/image"; import Link from "next/link"; import { ToastContainer, Zoom } from "react-toastify"; -import "../../globals.css"; -import { Suspense } from "react"; import GoogleAnalytics from "@/app/_components/GoogleAnalytics"; +import "@/app/globals.css"; +import { Suspense } from "react"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], diff --git a/app/_inquires/form.tsx b/app/_inquires/form.tsx index 0500d90..ca5af3e 100644 --- a/app/_inquires/form.tsx +++ b/app/_inquires/form.tsx @@ -43,25 +43,25 @@ const InquiresForm = ({ name: "full_name", label: "Full Name", placeholder: "", - required: false, + required: true, }, { name: "company_name", label: "Company Name", placeholder: "", - required: false, + required: true, }, { name: "work_email", label: "Work Email", placeholder: "", - required: false, + required: true, }, { name: "phone_number", label: "Phone Number", placeholder: "", - required: false, + required: true, }, ], [], @@ -123,6 +123,8 @@ const InquiresForm = ({ field.name.includes("email") || field.name === "work_email"; const isPhoneField = field.name.includes("phone") || field.name === "phone_number"; + const isStrictTextField = + field.name === "full_name" || field.name === "company_name"; if (isPhoneField) { return createFieldValidation(field.label, field.required, { @@ -150,6 +152,25 @@ const InquiresForm = ({ }); } + if (isStrictTextField) { + return createFieldValidation(field.label, field.required, { + minLength: field.required + ? { + value: 2, + message: `${field.label} must be at least 2 characters`, + } + : undefined, + maxLength: { + value: 200, + message: `${field.label} must not exceed 200 characters`, + }, + pattern: { + value: /^[\p{L}\p{M}\p{N}\s'.,()&/_-]+$/u, + message: `${field.label} may only include letters, numbers, spaces, and common punctuation (no HTML or code)`, + }, + }); + } + return createFieldValidation(field.label, field.required, { maxLength: { value: 200, diff --git a/lib/__tests__/apiErrorHandler.test.ts b/lib/__tests__/apiErrorHandler.test.ts index 36f02aa..df9c06e 100644 --- a/lib/__tests__/apiErrorHandler.test.ts +++ b/lib/__tests__/apiErrorHandler.test.ts @@ -35,12 +35,12 @@ describe("API Error Handler", () => { }; const result = parseApiError(error); - expect(result.generalMessage).toContain("Validation error"); - expect(result.fieldErrors.email).toBe("Invalid email format"); - expect(result.fieldErrors.full_name).toContain("Name is too short"); + expect(result.generalMessage).toContain("highlighted fields"); + expect(result.fieldErrors.email).toMatch(/email/i); + expect(result.fieldErrors.full_name).toMatch(/name|short|character/i); }); - it("should parse 400 errors with message only", () => { + it("should map message-only 400 responses without exposing raw server text", () => { const error = new Error("Bad Request") as AxiosError; error.isAxiosError = true; (error as AxiosError).response = { @@ -54,7 +54,47 @@ describe("API Error Handler", () => { }; const result = parseApiError(error); - expect(result.generalMessage).toBe("Custom validation error message"); + expect(result.generalMessage).not.toContain("Custom validation"); + expect(result.generalMessage.length).toBeGreaterThan(10); + }); + + it("should parse nested error object with field errors", () => { + const error = new Error("Bad Request") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 400, + data: { + error: { + code: "validation_failed", + errors: { work_email: "invalid_email" }, + }, + }, + statusText: "Bad Request", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.fieldErrors.work_email).toMatch(/email/i); + }); + + it("should parse FastAPI-style detail arrays", () => { + const error = new Error("Unprocessable") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 422, + data: { + detail: [ + { loc: ["body", "phone_number"], msg: "value is not a valid phone" }, + ], + }, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.fieldErrors.phone_number).toMatch(/phone/i); }); it("should handle 401 unauthorized errors", () => { @@ -69,7 +109,7 @@ describe("API Error Handler", () => { }; const result = parseApiError(error); - expect(result.generalMessage).toContain("Authentication required"); + expect(result.generalMessage).toMatch(/Authentication|Sign in/i); }); it("should handle 422 unprocessable entity errors", () => { @@ -88,7 +128,7 @@ describe("API Error Handler", () => { }; const result = parseApiError(error); - expect(result.generalMessage).toContain("Validation error"); + expect(result.generalMessage).toContain("highlighted fields"); expect(result.fieldErrors.phone).toContain("phone number"); }); @@ -104,7 +144,7 @@ describe("API Error Handler", () => { }; const result = parseApiError(error); - expect(result.generalMessage).toContain("Too many requests"); + expect(result.generalMessage).toMatch(/many|wait/i); }); it("should handle 500 server errors", () => { @@ -119,7 +159,23 @@ describe("API Error Handler", () => { }; const result = parseApiError(error); - expect(result.generalMessage).toContain("Server error"); + expect(result.generalMessage).toMatch(/server|try again/i); + }); + + it("should give a client-safe message for unlisted 4xx statuses with a body", () => { + const error = new Error("Nope") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 418, + data: { message: "I'm a teapot" }, + statusText: "I'm a teapot", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.generalMessage).not.toContain("teapot"); + expect(result.generalMessage.length).toBeGreaterThan(5); }); }); @@ -146,17 +202,15 @@ describe("API Error Handler", () => { handleApiError(error, mockSetError, mockShowToast, ["email", "phone"]); expect(mockSetError).toHaveBeenCalledTimes(2); - expect(mockSetError).toHaveBeenCalledWith("email", { - type: "server", - message: "Invalid email", - }); - expect(mockSetError).toHaveBeenCalledWith("phone", { - type: "server", - message: "Invalid phone", - }); - expect(mockShowToast).toHaveBeenCalledWith( - "Please correct the errors in the highlighted fields." + expect(mockSetError).toHaveBeenCalledWith( + "email", + expect.objectContaining({ type: "server", message: expect.any(String) }) ); + expect(mockSetError).toHaveBeenCalledWith( + "phone", + expect.objectContaining({ type: "server", message: expect.any(String) }) + ); + expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("highlighted fields")); }); it("should only set errors for fields in formFields list", () => { @@ -181,10 +235,10 @@ describe("API Error Handler", () => { handleApiError(error, mockSetError, mockShowToast, ["email"]); expect(mockSetError).toHaveBeenCalledTimes(1); - expect(mockSetError).toHaveBeenCalledWith("email", { - type: "server", - message: "Invalid email", - }); + expect(mockSetError).toHaveBeenCalledWith( + "email", + expect.objectContaining({ type: "server" }) + ); }); it("should show general message when no field errors", () => { @@ -204,7 +258,7 @@ describe("API Error Handler", () => { handleApiError(error, mockSetError, mockShowToast, ["email"]); expect(mockSetError).not.toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("Server error")); + expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/server|try again/i)); }); }); }); diff --git a/lib/apiErrorHandler.ts b/lib/apiErrorHandler.ts index 5e37b68..b8be4cc 100644 --- a/lib/apiErrorHandler.ts +++ b/lib/apiErrorHandler.ts @@ -1,19 +1,33 @@ /** * API Error Handler Utility - * Parses server error responses and extracts field-level validation errors + * Parses server error responses and extracts field-level validation errors. + * Server text is mapped to safe, user-facing copy — raw messages are not echoed. */ -import { AxiosError } from "axios"; +import axios, { AxiosError } from "axios"; import { FieldValues, Path, UseFormSetError } from "react-hook-form"; /** - * Standard API error response structure + * Standard API error response structure (and common variants we normalize). */ export interface ApiErrorResponse { message?: string; - errors?: Record; - error?: string; - detail?: string | Record; + errors?: Record | unknown; + error?: string | ApiNestedError | Record; + detail?: string | string[] | Record | FastApiDetailItem[]; +} + +interface ApiNestedError { + message?: string; + code?: string; + errors?: Record; + details?: Record; +} + +interface FastApiDetailItem { + loc?: unknown[]; + msg?: string; + type?: string; } /** @@ -24,9 +38,12 @@ export interface ParsedApiError { fieldErrors: Record; } -/** - * Field name mapping for common API field names to form field names - */ +const DEFAULT_GENERAL = "Something went wrong. Please try again."; + +/** Shown under a field when the server flagged it but we do not expose its raw text. */ +const GENERIC_FIELD_MESSAGE = + "This value could not be accepted. Check for typos and remove any HTML, scripts, or unusual symbols."; + const FIELD_NAME_MAP: Record = { fullName: "full_name", full_name: "full_name", @@ -41,106 +58,291 @@ const FIELD_NAME_MAP: Record = { work_email: "work_email", }; -/** - * Common error messages for validation types - */ const ERROR_MESSAGES: Record = { - invalid_input: "Invalid input detected. Please remove any special characters or scripts.", - xss_detected: "Invalid content detected. Please remove any HTML tags or scripts.", - validation_failed: "Validation failed. Please check your input.", + invalid_input: + "That does not look like valid input. Use plain text only — no HTML or scripts.", + xss_detected: + "Invalid content was detected. Remove any HTML tags, scripts, or code-like text.", + validation_failed: "Please check this field and try again.", required: "This field is required.", invalid_email: "Please enter a valid email address.", - invalid_phone: "Please enter a valid phone number.", - too_short: "Input is too short.", - too_long: "Input is too long.", + invalid_phone: "Please enter a valid phone number using digits and common phone symbols only.", + too_short: "This value is too short.", + too_long: "This value is too long.", + duplicate: "This value is already in use. Try a different one.", + duplicate_inquiry: + "You already have a pending inquiry with this email. Please wait for it to be processed.", }; /** - * Normalizes field name from API to form field name + * Maps a single server token or short code to a friendly message. */ +function mapErrorCode(code: string): string | null { + const key = code.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (ERROR_MESSAGES[key]) return ERROR_MESSAGES[key]; + const aliases: Record = { + invalid_email_format: "invalid_email", + email_invalid: "invalid_email", + invalid_email: "invalid_email", + invalid_phone_format: "invalid_phone", + invalid_phone: "invalid_phone", + phone_invalid: "invalid_phone", + xss: "xss_detected", + xss_detected: "xss_detected", + malicious_content: "xss_detected", + validation_error: "validation_failed", + validation_failed: "validation_failed", + duplicate_inquiry: "duplicate_inquiry", + duplicate: "duplicate", + }; + const mappedKey = aliases[key]; + return mappedKey ? ERROR_MESSAGES[mappedKey] : null; +} + +/** + * Infers a safe message from server hint text without echoing the full string. + */ +function inferMessageFromHint(hint: string): string { + const h = hint.toLowerCase(); + if (/(xss|script|html|tag|malicious|injection)/i.test(hint)) { + return ERROR_MESSAGES.xss_detected; + } + if (/email/.test(h)) return ERROR_MESSAGES.invalid_email; + if (/phone|tel|mobile/.test(h)) return ERROR_MESSAGES.invalid_phone; + if (/company|organization|organisation/.test(h)) { + return "Please use a valid company name (plain text, no HTML or scripts)."; + } + if (/name|full/.test(h)) { + return "Please enter a valid name using letters and common characters only."; + } + if (/required|missing|empty/.test(h)) return ERROR_MESSAGES.required; + if (/long|length|exceed/.test(h)) return ERROR_MESSAGES.too_long; + if (/short|minimum/.test(h)) return ERROR_MESSAGES.too_short; + if (/duplicate|already|exist/.test(h)) return ERROR_MESSAGES.duplicate_inquiry; + return GENERIC_FIELD_MESSAGE; +} + function normalizeFieldName(fieldName: string): string { return FIELD_NAME_MAP[fieldName] || fieldName; } +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + /** - * Extracts a human-readable error message from various error formats + * Turns assorted server error values into a single user-safe string. */ -function extractErrorMessage(error: string | string[] | unknown): string { - if (typeof error === "string") { - return ERROR_MESSAGES[error] || error; +function normalizeFieldErrorValue(value: unknown): string { + if (value === null || value === undefined) return GENERIC_FIELD_MESSAGE; + if (typeof value === "number" || typeof value === "boolean") { + return mapErrorCode(String(value)) ?? GENERIC_FIELD_MESSAGE; } - if (Array.isArray(error)) { - return error.map((e) => ERROR_MESSAGES[e] || e).join(". "); + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return GENERIC_FIELD_MESSAGE; + const byCode = mapErrorCode(trimmed); + if (byCode) return byCode; + if (/^[a-z0-9_.]+$/i.test(trimmed) && trimmed.length <= 80) { + return mapErrorCode(trimmed.replace(/\./g, "_")) ?? GENERIC_FIELD_MESSAGE; + } + return inferMessageFromHint(trimmed); } - return "Invalid input"; + if (Array.isArray(value)) { + const parts = value + .map((v) => normalizeFieldErrorValue(v)) + .filter((s, i, arr) => s && arr.indexOf(s) === i); + return parts.length ? parts.join(" ") : GENERIC_FIELD_MESSAGE; + } + if (isPlainObject(value)) { + const msg = + typeof value.message === "string" + ? value.message + : typeof value.msg === "string" + ? value.msg + : typeof value.detail === "string" + ? value.detail + : ""; + if (msg) return inferMessageFromHint(msg); + } + return GENERIC_FIELD_MESSAGE; +} + +function mergeFieldErrors( + target: Record, + source: Record +): void { + for (const [k, v] of Object.entries(source)) { + const key = normalizeFieldName(k); + target[key] = v; + } +} + +function parseErrorsRecord(record: Record): Record { + const out: Record = {}; + for (const [field, err] of Object.entries(record)) { + out[field] = normalizeFieldErrorValue(err); + } + return out; +} + +function parseFastApiDetailArray(items: FastApiDetailItem[]): Record { + const out: Record = {}; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const loc = Array.isArray(item.loc) ? item.loc : []; + const rawKey = loc.filter((x): x is string => typeof x === "string").pop(); + const field = rawKey ? normalizeFieldName(rawKey) : "_form"; + const hint = item.msg ?? item.type ?? ""; + out[field] = hint ? inferMessageFromHint(String(hint)) : GENERIC_FIELD_MESSAGE; + } + return out; +} + +function parseDetail(detail: unknown): Record { + if (!detail) return {}; + if (typeof detail === "string") { + return { _form: inferMessageFromHint(detail) }; + } + if (Array.isArray(detail)) { + if (detail.length && typeof detail[0] === "object" && detail[0] !== null && "loc" in detail[0]) { + return parseFastApiDetailArray(detail as FastApiDetailItem[]); + } + return { _form: normalizeFieldErrorValue(detail) }; + } + if (isPlainObject(detail)) { + return parseErrorsRecord(detail as Record); + } + return {}; +} + +/** + * Walks common API shapes and collects field errors + optional form-level key. + */ +function extractFieldErrorsFromBody(data: unknown): { + fieldErrors: Record; + formLevel?: string; +} { + const fieldErrors: Record = {}; + let formLevel: string | undefined; + + if (!data) return { fieldErrors }; + + if (typeof data === "string") { + formLevel = inferMessageFromHint(data); + return { fieldErrors, formLevel }; + } + + if (!isPlainObject(data)) return { fieldErrors }; + + const d = data as Record; + + if (isPlainObject(d.errors)) { + mergeFieldErrors(fieldErrors, parseErrorsRecord(d.errors as Record)); + } + + if (Array.isArray(d.errors)) { + mergeFieldErrors(fieldErrors, { _form: normalizeFieldErrorValue(d.errors) }); + } + + const detailParsed = parseDetail(d.detail); + mergeFieldErrors(fieldErrors, detailParsed); + if (detailParsed._form && !fieldErrors._form) { + fieldErrors._form = detailParsed._form; + } + + const err = d.error; + if (typeof err === "string") { + formLevel = inferMessageFromHint(err); + } else if (isPlainObject(err)) { + const ne = err as ApiNestedError; + if (typeof ne.message === "string") { + formLevel = inferMessageFromHint(ne.message); + } + if (typeof ne.code === "string") { + const byCode = mapErrorCode(ne.code); + if (byCode) formLevel = byCode; + } + if (isPlainObject(ne.errors)) { + mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.errors as Record)); + } + if (isPlainObject(ne.details)) { + mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.details as Record)); + } + } + + if (typeof d.message === "string" && !formLevel) { + formLevel = inferMessageFromHint(d.message); + } + + return { fieldErrors, formLevel }; +} + +function getAxiosResponse(error: unknown): AxiosError["response"] | undefined { + if (axios.isAxiosError(error)) { + return error.response; + } + if (error instanceof Error && "isAxiosError" in error && (error as AxiosError).isAxiosError) { + return (error as AxiosError).response; + } + return undefined; } /** * Parses API error response and extracts field-level errors - * @param error - The axios error object - * @returns Parsed error with general message and field-specific errors */ export function parseApiError(error: unknown): ParsedApiError { const result: ParsedApiError = { - generalMessage: "Something went wrong. Please try again.", + generalMessage: DEFAULT_GENERAL, fieldErrors: {}, }; - if (!(error instanceof Error)) { + const response = getAxiosResponse(error); + + if (!response) { + if (error instanceof Error) { + result.generalMessage = "Unable to connect to the server. Check your connection and try again."; + } return result; } - const axiosError = error as AxiosError; + const { status, data } = response; + const extracted = extractFieldErrorsFromBody(data); + Object.assign(result.fieldErrors, extracted.fieldErrors); - if (!axiosError.response) { - // Network error - result.generalMessage = "Unable to connect to server. Please check your connection."; - return result; - } + const validationSummary = + "Some information could not be accepted. Please review the highlighted fields below."; - const { status, data } = axiosError.response; - - // Handle different HTTP status codes - if (status === 400) { - result.generalMessage = "Validation error. Please check the highlighted fields."; - - // Extract field errors from different response formats - if (data?.errors) { - // Format: { errors: { field1: "error", field2: ["error1", "error2"] } } - Object.entries(data.errors).forEach(([field, error]) => { - const normalizedField = normalizeFieldName(field); - result.fieldErrors[normalizedField] = extractErrorMessage(error); - }); - } else if (data?.detail && typeof data.detail === "object") { - // Django REST framework format: { detail: { field1: ["error"] } } - Object.entries(data.detail).forEach(([field, errors]) => { - const normalizedField = normalizeFieldName(field); - result.fieldErrors[normalizedField] = extractErrorMessage(errors); - }); - } else if (data?.message) { - result.generalMessage = data.message; - } else if (data?.error) { - result.generalMessage = data.error; + if (status === 400 || status === 422) { + result.generalMessage = validationSummary; + if (extracted.formLevel && Object.keys(result.fieldErrors).length === 0) { + result.generalMessage = extracted.formLevel; } } else if (status === 401) { - result.generalMessage = "Authentication required. Please log in and try again."; + result.generalMessage = "Authentication is required. Sign in and try again."; } else if (status === 403) { - result.generalMessage = "You don't have permission to perform this action."; + result.generalMessage = "You do not have permission to perform this action."; } else if (status === 404) { result.generalMessage = "The requested resource was not found."; - } else if (status === 422) { - // Unprocessable Entity - validation error - result.generalMessage = "Validation error. Please check the highlighted fields."; - if (data?.errors) { - Object.entries(data.errors).forEach(([field, error]) => { - const normalizedField = normalizeFieldName(field); - result.fieldErrors[normalizedField] = extractErrorMessage(error); - }); - } + } else if (status === 409) { + result.generalMessage = ERROR_MESSAGES.duplicate_inquiry; } else if (status === 429) { - result.generalMessage = "Too many requests. Please wait a moment and try again."; + result.generalMessage = "Too many attempts. Please wait a moment and try again."; } else if (status >= 500) { - result.generalMessage = "Server error. Please try again later."; + result.generalMessage = "The server had a problem. Please try again in a few minutes."; + } else if (status >= 400 && status < 500) { + result.generalMessage = + Object.keys(result.fieldErrors).length > 0 + ? validationSummary + : extracted.formLevel ?? "We could not complete this request. Please check your input and try again."; + } + + if (result.fieldErrors._form) { + const formOnly = result.fieldErrors._form; + delete result.fieldErrors._form; + if (Object.keys(result.fieldErrors).length === 0) { + result.generalMessage = formOnly; + } } return result; @@ -148,10 +350,6 @@ export function parseApiError(error: unknown): ParsedApiError { /** * Handles API error and sets form field errors using react-hook-form - * @param error - The error from API call - * @param setError - react-hook-form setError function - * @param showToast - Function to show toast notification - * @param formFields - Optional list of form field names to validate against */ export function handleApiError( error: unknown, @@ -161,10 +359,8 @@ export function handleApiError( ): void { const parsedError = parseApiError(error); - // Set field-specific errors let hasFieldErrors = false; Object.entries(parsedError.fieldErrors).forEach(([field, message]) => { - // Only set error if field exists in form (or if formFields not specified) if (!formFields || formFields.includes(field)) { setError(field as Path, { type: "server", @@ -174,11 +370,5 @@ export function handleApiError( } }); - // Show toast with general message - // If we have field errors, use a more specific message - if (hasFieldErrors) { - showToast("Please correct the errors in the highlighted fields."); - } else { - showToast(parsedError.generalMessage); - } + showToast(parsedError.generalMessage); }