diff --git a/app/_contact-us/form.tsx b/app/_contact-us/form.tsx index d2b9071..19a402b 100644 --- a/app/_contact-us/form.tsx +++ b/app/_contact-us/form.tsx @@ -1,4 +1,6 @@ "use client"; +import { handleApiError } from "@/lib/apiErrorHandler"; +import { validationRules } from "@/lib/validation"; import api from "@/service/api"; import HCaptcha from "@hcaptcha/react-hcaptcha"; import { useRef, useState } from "react"; @@ -14,6 +16,9 @@ type TContactUsForm = { phone: string; message: string; }; + +const FORM_FIELDS = ["full_name", "email", "phone", "message"]; + interface IProps { columnsPerRow?: 1 | 2 | 3 | 4; buttonText?: string; @@ -30,6 +35,7 @@ const ContactUsForm = ({ handleSubmit, formState: { errors }, reset, + setError, } = useForm(); const onCaptchaVerify = (token: string) => { @@ -55,9 +61,7 @@ const ContactUsForm = ({ setHcaptchaToken(null); captchaRef.current?.resetCaptcha(); } catch (error) { - console.log(error); - toast.error("Something went wrong"); - throw error; + handleApiError(error, setError, toast.error, FORM_FIELDS); } finally { setIsLoading(() => false); } @@ -84,20 +88,7 @@ const ContactUsForm = ({ id="full_name" label="Full Name" register={register} - validation={{ - required: { - message: "This field is required", - value: true, - }, - minLength: { - value: 2, - message: "Please enter at least 2 characters", - }, - maxLength: { - value: 100, - message: "Please enter at most 100 characters", - }, - }} + validation={validationRules.fullName} type="text" error={errors.full_name?.message} /> @@ -105,16 +96,7 @@ const ContactUsForm = ({ id="email" label="Email" register={register} - validation={{ - required: { - message: "This field is required", - value: true, - }, - pattern: { - value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - message: "Please enter a valid email address", - }, - }} + validation={validationRules.email} type="email" error={errors.email?.message} /> @@ -122,24 +104,7 @@ const ContactUsForm = ({ id="phone" label="Phone" register={register} - validation={{ - required: { - message: "This field is required", - value: true, - }, - minLength: { - value: 10, - message: "Please enter at least 10 characters", - }, - maxLength: { - value: 20, - message: "Please enter at most 20 characters", - }, - pattern: { - value: /^[+\d]+$/, - message: "Please enter a valid phone number", - }, - }} + validation={validationRules.phone} type="text" error={errors.phone?.message} /> @@ -148,20 +113,7 @@ const ContactUsForm = ({ label="Message" register={register} error={errors.message?.message} - validation={{ - required: { - value: true, - message: "This field is required", - }, - maxLength: { - value: 1000, - message: "Please enter at most 1000 characters", - }, - minLength: { - value: 10, - message: "Please enter at least 10 characters", - }, - }} + validation={validationRules.message} />
(); + const defaultFields = useMemo( + () => [ + { + name: "full_name", + label: "Full Name", + placeholder: "", + required: false, + }, + { + name: "company_name", + label: "Company Name", + placeholder: "", + required: false, + }, + { + name: "work_email", + label: "Work Email", + placeholder: "", + required: false, + }, + { + name: "phone_number", + label: "Phone Number", + placeholder: "", + required: false, + }, + ], + [] + ); + + const fields = contactData?.fields || defaultFields; + const formFieldNames = useMemo(() => fields.map((f) => f.name), [fields]); + const onSubmit = async (data: TInquiresForm) => { setIsLoading(true); try { @@ -38,9 +74,7 @@ const InquiresForm = ({ toast.success(response.message); reset(); } catch (error) { - console.log(error); - toast.error("Something went wrong"); - throw error; + handleApiError(error, setError, toast.error, formFieldNames); } finally { setIsLoading(false); } @@ -60,32 +94,56 @@ const InquiresForm = ({ 4: "lg:col-span-4", }[columnsPerRow]; - const defaultFields = [ - { name: "full_name", label: "Full Name", placeholder: "", required: false }, - { - name: "company_name", - label: "Company Name", - placeholder: "", - required: false, - }, - { - name: "work_email", - label: "Work Email", - placeholder: "", - required: false, - }, - { - name: "phone_number", - label: "Phone Number", - placeholder: "", - required: false, - }, - ]; - - const fields = contactData?.fields || defaultFields; const submitText = sanitizeText(contactData?.submit_button_text) || buttonText || "Submit"; + /** + * Creates validation rules based on field type with XSS protection + */ + const getFieldValidation = (field: { + name: string; + label: string; + required?: boolean; + }) => { + const isEmailField = + field.name.includes("email") || field.name === "work_email"; + const isPhoneField = + field.name.includes("phone") || field.name === "phone_number"; + + if (isPhoneField) { + return createFieldValidation(field.label, field.required, { + pattern: { + value: /^[+\d\s()-]+$/, + message: + "Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)", + }, + minLength: field.required + ? { value: 10, message: "Phone number must be at least 10 digits" } + : undefined, + maxLength: { + value: 20, + message: "Phone number must not exceed 20 characters", + }, + }); + } + + if (isEmailField) { + return createFieldValidation(field.label, field.required, { + pattern: { + value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "Please enter a valid email address", + }, + }); + } + + return createFieldValidation(field.label, field.required, { + maxLength: { + value: 200, + message: `${field.label} must not exceed 200 characters`, + }, + }); + }; + return (
); })} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..ab2214b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/lib"], + testMatch: ["**/__tests__/**/*.test.ts"], + moduleNameMapper: { + "^@/(.*)$": "/$1", + }, +}; diff --git a/lib/__tests__/apiErrorHandler.test.ts b/lib/__tests__/apiErrorHandler.test.ts new file mode 100644 index 0000000..36f02aa --- /dev/null +++ b/lib/__tests__/apiErrorHandler.test.ts @@ -0,0 +1,210 @@ +import { AxiosError } from "axios"; +import { handleApiError, parseApiError } from "../apiErrorHandler"; + +describe("API Error Handler", () => { + describe("parseApiError", () => { + it("should return default message for non-Error objects", () => { + const result = parseApiError("string error"); + expect(result.generalMessage).toBe("Something went wrong. Please try again."); + expect(result.fieldErrors).toEqual({}); + }); + + it("should handle network errors (no response)", () => { + const error = new Error("Network Error") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = undefined; + + const result = parseApiError(error); + expect(result.generalMessage).toContain("Unable to connect"); + }); + + it("should parse 400 errors with field-level errors", () => { + const error = new Error("Bad Request") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 400, + data: { + errors: { + email: "Invalid email format", + full_name: ["Name is too short", "Name contains invalid characters"], + }, + }, + statusText: "Bad Request", + headers: {}, + config: {} as any, + }; + + 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"); + }); + + it("should parse 400 errors with message only", () => { + const error = new Error("Bad Request") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 400, + data: { + message: "Custom validation error message", + }, + statusText: "Bad Request", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.generalMessage).toBe("Custom validation error message"); + }); + + it("should handle 401 unauthorized errors", () => { + const error = new Error("Unauthorized") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 401, + data: {}, + statusText: "Unauthorized", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.generalMessage).toContain("Authentication required"); + }); + + it("should handle 422 unprocessable entity errors", () => { + const error = new Error("Unprocessable Entity") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 422, + data: { + errors: { + phone: "invalid_phone", + }, + }, + statusText: "Unprocessable Entity", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.generalMessage).toContain("Validation error"); + expect(result.fieldErrors.phone).toContain("phone number"); + }); + + it("should handle 429 rate limit errors", () => { + const error = new Error("Too Many Requests") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 429, + data: {}, + statusText: "Too Many Requests", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.generalMessage).toContain("Too many requests"); + }); + + it("should handle 500 server errors", () => { + const error = new Error("Internal Server Error") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 500, + data: {}, + statusText: "Internal Server Error", + headers: {}, + config: {} as any, + }; + + const result = parseApiError(error); + expect(result.generalMessage).toContain("Server error"); + }); + }); + + describe("handleApiError", () => { + it("should call setError for each field error", () => { + const mockSetError = jest.fn(); + const mockShowToast = jest.fn(); + + const error = new Error("Bad Request") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 400, + data: { + errors: { + email: "Invalid email", + phone: "Invalid phone", + }, + }, + statusText: "Bad Request", + headers: {}, + config: {} as any, + }; + + 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." + ); + }); + + it("should only set errors for fields in formFields list", () => { + const mockSetError = jest.fn(); + const mockShowToast = jest.fn(); + + const error = new Error("Bad Request") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 400, + data: { + errors: { + email: "Invalid email", + unknown_field: "Unknown error", + }, + }, + statusText: "Bad Request", + headers: {}, + config: {} as any, + }; + + handleApiError(error, mockSetError, mockShowToast, ["email"]); + + expect(mockSetError).toHaveBeenCalledTimes(1); + expect(mockSetError).toHaveBeenCalledWith("email", { + type: "server", + message: "Invalid email", + }); + }); + + it("should show general message when no field errors", () => { + const mockSetError = jest.fn(); + const mockShowToast = jest.fn(); + + const error = new Error("Internal Server Error") as AxiosError; + error.isAxiosError = true; + (error as AxiosError).response = { + status: 500, + data: {}, + statusText: "Internal Server Error", + headers: {}, + config: {} as any, + }; + + handleApiError(error, mockSetError, mockShowToast, ["email"]); + + expect(mockSetError).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("Server error")); + }); + }); +}); diff --git a/lib/__tests__/validation.test.ts b/lib/__tests__/validation.test.ts new file mode 100644 index 0000000..8fc3424 --- /dev/null +++ b/lib/__tests__/validation.test.ts @@ -0,0 +1,121 @@ +import { + containsDangerousContent, + createFieldValidation, + createXSSValidator, + validationRules, +} from "../validation"; + +describe("Validation Functions", () => { + describe("containsDangerousContent", () => { + it("should detect script tags", () => { + expect(containsDangerousContent("")).toBe(true); + expect(containsDangerousContent("")).toBe(true); + }); + + it("should detect javascript: protocol", () => { + expect(containsDangerousContent("javascript:alert('XSS')")).toBe(true); + expect(containsDangerousContent("JAVASCRIPT:alert('XSS')")).toBe(true); + }); + + it("should detect event handlers", () => { + expect(containsDangerousContent("onclick=alert('XSS')")).toBe(true); + expect(containsDangerousContent("onerror=alert('XSS')")).toBe(true); + expect(containsDangerousContent("onload=alert('XSS')")).toBe(true); + }); + + it("should detect iframe tags", () => { + expect(containsDangerousContent("