From 3526b39350b194228def1082d7ed37eaa32c3663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Comeau?= Date: Wed, 15 Jan 2025 12:54:14 -0400 Subject: [PATCH] feature(frontend): reusable zod validation schemas --- frontend/.vscode/settings.json | 2 - .../email-address-validation-schema.ts | 87 +++++ .../first-name-validation-schema.ts | 72 ++++ frontend/app/.server/validation/index.ts | 5 + .../validation/last-name-validation-schema.ts | 72 ++++ .../validation/name-validation-schema.ts | 56 ++++ .../validation/string-validation-schema.ts | 126 +++++++ frontend/app/@types/global.d.ts | 17 + frontend/app/utils/zod-utils.ts | 36 ++ frontend/package-lock.json | 18 + frontend/package.json | 2 + .../email-validation-schema.test.ts | 234 +++++++++++++ .../first-name-validation-schema.test.ts | 186 +++++++++++ .../last-name-validation-schema.test.ts | 186 +++++++++++ .../validation/name-validation-schema.test.ts | 186 +++++++++++ .../string-validation-schema.test.ts | 307 ++++++++++++++++++ frontend/tsconfig.json | 2 + frontend/vite.config.ts | 6 + 18 files changed, 1598 insertions(+), 2 deletions(-) create mode 100644 frontend/app/.server/validation/email-address-validation-schema.ts create mode 100644 frontend/app/.server/validation/first-name-validation-schema.ts create mode 100644 frontend/app/.server/validation/index.ts create mode 100644 frontend/app/.server/validation/last-name-validation-schema.ts create mode 100644 frontend/app/.server/validation/name-validation-schema.ts create mode 100644 frontend/app/.server/validation/string-validation-schema.ts create mode 100644 frontend/app/utils/zod-utils.ts create mode 100644 frontend/tests/.server/validation/email-validation-schema.test.ts create mode 100644 frontend/tests/.server/validation/first-name-validation-schema.test.ts create mode 100644 frontend/tests/.server/validation/last-name-validation-schema.test.ts create mode 100644 frontend/tests/.server/validation/name-validation-schema.test.ts create mode 100644 frontend/tests/.server/validation/string-validation-schema.test.ts diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index ca137c50..0d434908 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -16,8 +16,6 @@ // clsx and cn ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], - // Plain Javascript Object - ":\\s*?[\"'`]([^\"'`]*).*?,", // JavaScript string variable with keywords [ "(?:\\b(?:const|let|var)\\s+)?[\\w$_]*(?:[Ss]tyles|[Cc]lasses|[Cc]lassnames)[\\w\\d]*\\s*(?:=|\\+=)\\s*['\"]([^'\"]*)['\"]" diff --git a/frontend/app/.server/validation/email-address-validation-schema.ts b/frontend/app/.server/validation/email-address-validation-schema.ts new file mode 100644 index 00000000..a21c4c5e --- /dev/null +++ b/frontend/app/.server/validation/email-address-validation-schema.ts @@ -0,0 +1,87 @@ +import isEmail from 'validator/es/lib/isEmail'; +import type { z } from 'zod'; + +import { createStringValidationSchema } from '~/.server/validation/string-validation-schema'; + +/** + * Interface defining customizable error messages for email address validation schema. + */ +export interface EmailAddressValidationSchemaErrorMessages { + /** + * Error message for when the email address format is invalid. + * @default 'Email address format is invalid.' + */ + format_error?: string; + + /** + * Error message for when the email address is not a string. + * @default 'Email address must be a string.' + */ + invalid_type_error?: string; + + /** + * Error message for when the email address exceeds the maximum length. + * @default 'Email address must be less than or equal to {maximum} characters + */ + max_length_error?: string; + + /** + * Error message for when the email address is required. + * @default 'Email address is required.' + */ + required_error?: string; +} + +/** + * Configuration options for email address validation, including maximum length and error messages. + */ +export interface EmailAddressValidationSchemaOptions { + errorMessages?: EmailAddressValidationSchemaErrorMessages; + maxLength?: number; +} + +const DEFAULT_MESSAGES = { + format_error: 'Email address format is invalid.', + invalid_type_error: 'Email address must be a string.', + max_length_error: 'Email address must be less than or equal to {maximum} characters.', + required_error: 'Email address is required.', +} as const satisfies Required; + +/** + * Creates a Zod schema for validating names with customizable options. + * + * @param options - Configuration options for validation. + * @returns A Zod schema for validating names. + */ +export function createEmailAddressValidationSchema( + options: EmailAddressValidationSchemaOptions = {}, +): z.ZodEffects { + const defaultMaxEmailLength = 254; // matches validator.js + + const { errorMessages = {}, maxLength = defaultMaxEmailLength } = options; + + const messages: Required = { + ...DEFAULT_MESSAGES, + ...errorMessages, + }; + + return createStringValidationSchema({ + errorMessages: { + invalid_type_error: messages.invalid_type_error, + max_length_error: messages.max_length_error, + min_length_error: messages.required_error, + required_error: messages.required_error, + }, + minLength: 1, + maxLength: Math.min(defaultMaxEmailLength, maxLength), + }).superRefine((email, ctx) => { + if (!email) return; + if (isEmail(email)) return; + + ctx.addIssue({ + code: 'custom', + message: messages.format_error, + fatal: true, + }); + }); +} diff --git a/frontend/app/.server/validation/first-name-validation-schema.ts b/frontend/app/.server/validation/first-name-validation-schema.ts new file mode 100644 index 00000000..6a6bc7c9 --- /dev/null +++ b/frontend/app/.server/validation/first-name-validation-schema.ts @@ -0,0 +1,72 @@ +import type { z } from 'zod'; + +import { createNameValidationSchema } from '~/.server/validation/name-validation-schema'; + +/** + * Interface defining customizable error messages for firstName validation schema. + */ +export interface FirstNameValidationSchemaErrorMessages { + /** + * Error message for when the first name contains digits. + * @default 'First name must not contain any digits.' + */ + format_error?: string; + + /** + * Error message for when the first name is not a string. + * @default 'First name must be a string.' + */ + invalid_type_error?: string; + + /** + * Error message for when the first name exceeds the maximum length. + * @default 'First name must contain at most {maximum} characters.' + */ + max_length_error?: string; + + /** + * Error message for when the first name is required. + * @default 'First name is required.' + */ + required_error?: string; +} + +/** + * Configuration options for firstName validation, including maximum length and error messages. + */ +export interface FirstNameValidationSchemaOptions { + errorMessages?: FirstNameValidationSchemaErrorMessages; + maxLength?: number; +} + +const DEFAULT_MESSAGES = { + format_error: 'First name must not contain any digits.', + invalid_type_error: 'First name must be a string.', + max_length_error: 'First name must contain at most {maximum} characters.', + required_error: 'First name is required.', +} as const satisfies Required; + +/** + * Creates a Zod schema for validating firstNames with customizable options. + * + * @param options - Configuration options for validation. + * @returns A Zod schema for validating firstNames. + */ +export function createFirstNameValidationSchema(options: FirstNameValidationSchemaOptions = {}): z.ZodString { + const { errorMessages = {}, maxLength = 100 } = options; + + const messages: Required = { + ...DEFAULT_MESSAGES, + ...errorMessages, + }; + + return createNameValidationSchema({ + errorMessages: { + format_error: messages.format_error, + invalid_type_error: messages.invalid_type_error, + max_length_error: messages.max_length_error, + required_error: messages.required_error, + }, + maxLength, + }); +} diff --git a/frontend/app/.server/validation/index.ts b/frontend/app/.server/validation/index.ts new file mode 100644 index 00000000..43d8178a --- /dev/null +++ b/frontend/app/.server/validation/index.ts @@ -0,0 +1,5 @@ +export * from './email-address-validation-schema'; +export * from './first-name-validation-schema'; +export * from './last-name-validation-schema'; +export * from './name-validation-schema'; +export * from './string-validation-schema'; diff --git a/frontend/app/.server/validation/last-name-validation-schema.ts b/frontend/app/.server/validation/last-name-validation-schema.ts new file mode 100644 index 00000000..f35bec49 --- /dev/null +++ b/frontend/app/.server/validation/last-name-validation-schema.ts @@ -0,0 +1,72 @@ +import type { z } from 'zod'; + +import { createNameValidationSchema } from '~/.server/validation/name-validation-schema'; + +/** + * Interface defining customizable error messages for last name validation schema. + */ +export interface LastNameValidationSchemaErrorMessages { + /** + * Error message for when the last name contains digits. + * @default 'Last name must not contain any digits.' + */ + format_error?: string; + + /** + * Error message for when the last name is not a string. + * @default 'Last name must be a string.' + */ + invalid_type_error?: string; + + /** + * Error message for when the last name exceeds the maximum length. + * @default 'Last name must contain at most {maximum} characters.' + */ + max_length_error?: string; + + /** + * Error message for when the last name is required. + * @default 'Last name is required.' + */ + required_error?: string; +} + +/** + * Configuration options for last name validation, including maximum length and error messages. + */ +export interface LastNameValidationSchemaOptions { + errorMessages?: LastNameValidationSchemaErrorMessages; + maxLength?: number; +} + +const DEFAULT_MESSAGES = { + format_error: 'Last name must not contain any digits.', + invalid_type_error: 'Last name must be a string.', + max_length_error: 'Last name must contain at most {maximum} characters.', + required_error: 'Last name is required.', +} as const satisfies Required; + +/** + * Creates a Zod schema for validating last names with customizable options. + * + * @param options - Configuration options for validation. + * @returns A Zod schema for validating last names. + */ +export function createLastNameValidationSchema(options: LastNameValidationSchemaOptions = {}): z.ZodString { + const { errorMessages = {}, maxLength = 100 } = options; + + const messages: Required = { + ...DEFAULT_MESSAGES, + ...errorMessages, + }; + + return createNameValidationSchema({ + errorMessages: { + format_error: messages.format_error, + invalid_type_error: messages.invalid_type_error, + max_length_error: messages.max_length_error, + required_error: messages.required_error, + }, + maxLength, + }); +} diff --git a/frontend/app/.server/validation/name-validation-schema.ts b/frontend/app/.server/validation/name-validation-schema.ts new file mode 100644 index 00000000..a7ea2b40 --- /dev/null +++ b/frontend/app/.server/validation/name-validation-schema.ts @@ -0,0 +1,56 @@ +import type { z } from 'zod'; + +import { createStringValidationSchema } from '~/.server/validation/string-validation-schema'; + +/** + * Interface defining customizable error messages for name validation schema. + */ +export interface NameValidationSchemaErrorMessages { + format_error?: string; + invalid_type_error?: string; + max_length_error?: string; + required_error?: string; +} + +/** + * Configuration options for name validation, including maximum length and error messages. + */ +export interface NameValidationSchemaOptions { + errorMessages?: NameValidationSchemaErrorMessages; + maxLength?: number; +} + +const DEFAULT_MESSAGES = { + format_error: 'Name must not contain any digits.', + invalid_type_error: 'Name must be a string.', + max_length_error: 'Name must contain at most {maximum} characters.', + required_error: 'Name is required.', +} as const satisfies Required; + +/** + * Creates a Zod schema for validating names with customizable options. + * + * @param options - Configuration options for validation. + * @returns A Zod schema for validating names. + */ +export function createNameValidationSchema(options: NameValidationSchemaOptions = {}): z.ZodString { + const { errorMessages = {}, maxLength = 100 } = options; + + const messages: Required = { + ...DEFAULT_MESSAGES, + ...errorMessages, + }; + + return createStringValidationSchema({ + errorMessages: { + format_error: messages.format_error, + invalid_type_error: messages.invalid_type_error, + max_length_error: messages.max_length_error, + min_length_error: messages.required_error, + required_error: messages.required_error, + }, + format: 'non-digit', + minLength: 1, + maxLength, + }); +} diff --git a/frontend/app/.server/validation/string-validation-schema.ts b/frontend/app/.server/validation/string-validation-schema.ts new file mode 100644 index 00000000..99f26cf6 --- /dev/null +++ b/frontend/app/.server/validation/string-validation-schema.ts @@ -0,0 +1,126 @@ +import { z } from 'zod'; + +/** + * String format options for validation + */ +type StringFormat = 'alpha-only' | 'alphanumeric' | 'digit-only' | 'non-digit' | RegExp; + +/** + * Custom error messages for the validator + */ +export interface StringValidationSchemaErrorMessages { + format_error?: string; + invalid_type_error?: string; + max_length_error?: string; + min_length_error?: string; + required_error?: string; +} + +/** + * Configuration options for the string validator + */ +export interface StringValidationSchemaOptions { + /** + * Custom error messages for different validation rules + */ + errorMessages?: StringValidationSchemaErrorMessages; + + /** + * String format validation + * @default undefined (any) + */ + format?: StringFormat; + + /** + * Maximum allowed length of the string + * @default undefined (no maximum length) + */ + maxLength?: number; + + /** + * Miminum allowed length of the string + * @default undefined (no minimum length) + */ + minLength?: number; + + /** + * Whether the string should be trimmed before validation + * @default true + */ + trim?: boolean; +} + +/** + * Predefined regex patterns for different string formats + */ +const FORMAT_PATTERNS = { + 'alpha-only': /^[a-zA-Z]+$/, + 'alphanumeric': /^[a-zA-Z0-9]+$/, + 'digit-only': /^\d+$/, + 'non-digit': /^\D+$/, +} as const; + +/** + * Default error messages + */ +const DEFAULT_MESSAGES = { + format_error: 'Invalid format', + invalid_type_error: 'Expected string, received {received}', + max_length_error: 'String must contain at most {maximum} characters', + min_length_error: 'String must contain at least {minimum} characters', + required_error: 'Required', +} as const satisfies Required; + +/** + * Creates a Zod string validator with enhanced character validation + * + * @param options - Configuration options for the validator + * @returns A Zod string schema that always trims input + */ +export function createStringValidationSchema(options: StringValidationSchemaOptions = {}): z.ZodString { + const { errorMessages = {}, format, maxLength, minLength, trim = true } = options; + + const messages: Required = { + ...DEFAULT_MESSAGES, + ...errorMessages, + }; + + let schema = z.string({ + errorMap: (issue, ctx) => { + const inputData = String(ctx.data); + + if (inputData === z.ZodParsedType.undefined) { + return { message: messages.required_error }; + } + + if (issue.code === 'invalid_type') { + return { message: messages.invalid_type_error.replace('{received}', inputData) }; + } + + return { message: ctx.defaultError }; + }, + }); + + // trim whitespace + if (trim) { + schema = schema.trim(); + } + + // mininum length + if (typeof minLength === 'number') { + schema = schema.min(minLength, messages.min_length_error.replace('{minimum}', minLength.toString())); + } + + // maximum length + if (typeof maxLength === 'number') { + schema = schema.max(maxLength, messages.max_length_error.replace('{maximum}', maxLength.toString())); + } + + // format validation + if (format) { + const pattern = format instanceof RegExp ? format : FORMAT_PATTERNS[format]; + schema = schema.regex(pattern, messages.format_error); + } + + return schema; +} diff --git a/frontend/app/@types/global.d.ts b/frontend/app/@types/global.d.ts index 706094b3..ff1c1ef0 100644 --- a/frontend/app/@types/global.d.ts +++ b/frontend/app/@types/global.d.ts @@ -31,6 +31,23 @@ declare global { * scope, but doesn't declare them anywhere. */ var __reactRouterRouteModules: RouteModules; + + /** + * Extract from `T` those types that are assignable to `U`, where `U` must exist in `T`. + * + * Similar to `Extract` but requires the extraction list to be composed of valid members of `T`. + * + * @see https://github.com/pelotom/type-zoo?tab=readme-ov-file#extractstrictt-u-extends-t + */ + type ExtractStrict = T extends U ? T : never; + + /** + * Drop keys `K` from `T`, where `K` must exist in `T`. + * + * @see https://github.com/pelotom/type-zoo?tab=readme-ov-file#omitstrictt-k-extends-keyof-t + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type OmitStrict = T extends any ? Pick> : never; } export {}; diff --git a/frontend/app/utils/zod-utils.ts b/frontend/app/utils/zod-utils.ts new file mode 100644 index 00000000..df89695c --- /dev/null +++ b/frontend/app/utils/zod-utils.ts @@ -0,0 +1,36 @@ +/** + * Transforms a flattened error object by mapping each field to its first error message. + * + * @param flattenedError - An object containing `fieldErrors`, which is a record of field names to an array of error messages. + * @returns An object where each field is mapped to its first error message, or `undefined` if no error message is present. + * + * @example + * // Input: + * // { + * // fieldErrors: { + * // name: ["Name is required", "Name is too short"], + * // age: ["Age must be a number"] + * // } + * // } + * // Output: + * // { + * // name: "Name is required", + * // age: "Age must be a number" + * // } + * + * const flattenedError = { + * fieldErrors: { + * name: ["Name is required", "Name is too short"], + * age: ["Age must be a number"] + * } + * }; + * const result = transformFlattenedError(flattenedError); + * console.log(result); + * // Output: { name: "Name is required", age: "Age must be a number" } + */ +export function transformFlattenedError }>( + flattenedError: T, +): { [K in keyof T['fieldErrors']]: string | undefined } { + const transformedEntries = Object.entries(flattenedError.fieldErrors).map(([key, value]) => [key, value.at(0)]); + return Object.fromEntries(transformedEntries); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0d69d7fb..8ebf3682 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "react-router": "^7.1.2", "source-map-support": "^0.5.21", "tailwind-merge": "^2.6.0", + "validator": "^13.12.0", "winston": "^3.17.0", "winston-error-format": "^3.0.1", "zod": "^3.24.1" @@ -65,6 +66,7 @@ "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@types/source-map-support": "^0.5.10", + "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/parser": "^8.20.0", "@vitejs/plugin-react": "^4.3.4", @@ -4364,6 +4366,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.12.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", + "integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", @@ -12808,6 +12817,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 584ae0d2..bc8b1139 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,6 +56,7 @@ "react-router": "^7.1.2", "source-map-support": "^0.5.21", "tailwind-merge": "^2.6.0", + "validator": "^13.12.0", "winston": "^3.17.0", "winston-error-format": "^3.0.1", "zod": "^3.24.1" @@ -77,6 +78,7 @@ "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@types/source-map-support": "^0.5.10", + "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/parser": "^8.20.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/frontend/tests/.server/validation/email-validation-schema.test.ts b/frontend/tests/.server/validation/email-validation-schema.test.ts new file mode 100644 index 00000000..5390a862 --- /dev/null +++ b/frontend/tests/.server/validation/email-validation-schema.test.ts @@ -0,0 +1,234 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { createEmailAddressValidationSchema } from '~/.server/validation/email-address-validation-schema'; + +describe('createEmailAddressValidationSchema', () => { + it('should create a basic email address schema', () => { + const schema = createEmailAddressValidationSchema(); + + let result = schema.safeParse('test@example.com'); + assert(result.success); + expect(result.data).toBe('test@example.com'); + + result = schema.safeParse('test'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'custom', + fatal: true, + message: 'Email address format is invalid.', + path: [], + }, + ]); + + result = schema.safeParse('123'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'custom', + fatal: true, + message: 'Email address format is invalid.', + path: [], + }, + ]); + }); + + it('should trim whitespace by default', () => { + const schema = createEmailAddressValidationSchema(); + + const result = schema.safeParse(' test@example.com '); + assert(result.success); + expect(result.data).toBe('test@example.com'); + }); + + it('should validate maxLength', () => { + const schema = createEmailAddressValidationSchema({ maxLength: 14 }); + + let result = schema.safeParse('test@test.com'); // Short, valid email + assert(result.success); + expect(result.data).toBe('test@test.com'); + + result = schema.safeParse('test@example.com'); // Normal length, will be trimmed and fail + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 14, + message: 'Email address must be less than or equal to 14 characters.', + path: [], + type: 'string', + }, + ]); + }); + + it('should validate format (valid email)', () => { + const schema = createEmailAddressValidationSchema(); + + let result = schema.safeParse('test@example.com'); + assert(result.success); + expect(result.data).toBe('test@example.com'); + + result = schema.safeParse('test'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'custom', + fatal: true, + message: 'Email address format is invalid.', + path: [], + }, + ]); + }); + + it('should validate against a maximum of 254 characters, even if maxLength is higher', () => { + const longEmail = 'a'.repeat(64) + '@' + generateDomainName(189); // 254 characters + const tooLongEmail = 'b'.repeat(64) + '@' + generateDomainName(190); // 255 characters + + const schema = createEmailAddressValidationSchema({ maxLength: 300 }); + + let result = schema.safeParse(longEmail); + assert(result.success); + expect(result.data).toBe(longEmail); + + result = schema.safeParse(tooLongEmail); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 254, + message: 'Email address must be less than or equal to 254 characters.', + path: [], + type: 'string', + }, + { + code: 'custom', + fatal: true, + message: 'Email address format is invalid.', + path: [], + }, + ]); + }); + + it('should use custom error messages', () => { + const schema = createEmailAddressValidationSchema({ + errorMessages: { + required_error: 'Custom required message', + invalid_type_error: 'Custom type message', + max_length_error: 'Custom max length message', + format_error: 'Custom format message', + }, + maxLength: 10, + }); + + let result = schema.safeParse(undefined); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom required message', + path: [], + received: 'undefined', + }, + ]); + + result = schema.safeParse(''); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + message: 'Custom required message', + minimum: 1, + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse(null); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'null', + }, + ]); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'number', + }, + ]); + + result = schema.safeParse('test@example.com'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 10, + message: 'Custom max length message', + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse('test'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'custom', + fatal: true, + message: 'Custom format message', + path: [], + }, + ]); + }); + + it('should correctly replace {{length}} in error messages', () => { + const maxLength = 10; + const schema = createEmailAddressValidationSchema({ maxLength }); + + const result = schema.safeParse('test@example.com'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: maxLength, + message: `Email address must be less than or equal to ${maxLength} characters.`, + path: [], + type: 'string', + }, + ]); + }); + + function generateDomainName(maxLength: number) { + const getRandomPart = () => Math.random().toString(36).substring(2, 7); + let domain = ''; + + while (domain.length + 4 < maxLength) { + // Leave space for ".com" + domain += (domain ? '.' : '') + getRandomPart(); + } + + // Trim to ensure the domain name is exactly maxLength including ".com" + return domain.substring(0, maxLength - 4) + '.com'; + } +}); diff --git a/frontend/tests/.server/validation/first-name-validation-schema.test.ts b/frontend/tests/.server/validation/first-name-validation-schema.test.ts new file mode 100644 index 00000000..9a24ea59 --- /dev/null +++ b/frontend/tests/.server/validation/first-name-validation-schema.test.ts @@ -0,0 +1,186 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { createFirstNameValidationSchema } from '~/.server/validation/first-name-validation-schema'; + +describe('createFirstNameValidationSchema', () => { + it('should create a basic first name schema', () => { + const schema = createFirstNameValidationSchema(); + + let result = schema.safeParse('John'); + assert(result.success); + expect(result.data).toBe('John'); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'First name must be a string.', + path: [], + received: 'number', + }, + ]); + }); + + it('should trim whitespace by default', () => { + const schema = createFirstNameValidationSchema(); + + const result = schema.safeParse(' John '); + assert(result.success); + expect(result.data).toBe('John'); + }); + + it('should validate maxLength', () => { + const schema = createFirstNameValidationSchema({ maxLength: 3 }); + + let result = schema.safeParse('Joe'); + assert(result.success); + expect(result.data).toBe('Joe'); + + result = schema.safeParse('John'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 3, + message: 'First name must contain at most 3 characters.', + path: [], + type: 'string', + }, + ]); + }); + + it('should validate format (non-digit)', () => { + const schema = createFirstNameValidationSchema(); + + let result = schema.safeParse('John'); + assert(result.success); + expect(result.data).toBe('John'); + + result = schema.safeParse('John1'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'First name must not contain any digits.', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should use custom error messages', () => { + const schema = createFirstNameValidationSchema({ + errorMessages: { + required_error: 'Custom required message', + invalid_type_error: 'Custom type message', + max_length_error: 'Custom max length message', + format_error: 'Custom format message', + }, + maxLength: 3, + }); + + let result = schema.safeParse(undefined); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom required message', + path: [], + received: 'undefined', + }, + ]); + + result = schema.safeParse(''); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + message: 'Custom required message', + minimum: 1, + path: [], + type: 'string', + }, + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + + result = schema.safeParse(null); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'null', + }, + ]); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'number', + }, + ]); + + result = schema.safeParse('John'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 3, + message: 'Custom max length message', + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse('Jo1'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should correctly replace {{length}} in error messages', () => { + const maxLength = 3; + const schema = createFirstNameValidationSchema({ maxLength }); + + const result = schema.safeParse('John'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: maxLength, + message: `First name must contain at most ${maxLength} characters.`, + path: [], + type: 'string', + }, + ]); + }); +}); diff --git a/frontend/tests/.server/validation/last-name-validation-schema.test.ts b/frontend/tests/.server/validation/last-name-validation-schema.test.ts new file mode 100644 index 00000000..56e0db1d --- /dev/null +++ b/frontend/tests/.server/validation/last-name-validation-schema.test.ts @@ -0,0 +1,186 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { createLastNameValidationSchema } from '~/.server/validation/last-name-validation-schema'; + +describe('createLastNameValidationSchema', () => { + it('should create a basic last name schema', () => { + const schema = createLastNameValidationSchema(); + + let result = schema.safeParse('Smith'); + assert(result.success); + expect(result.data).toBe('Smith'); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Last name must be a string.', + path: [], + received: 'number', + }, + ]); + }); + + it('should trim whitespace by default', () => { + const schema = createLastNameValidationSchema(); + + const result = schema.safeParse(' Smith '); + assert(result.success); + expect(result.data).toBe('Smith'); + }); + + it('should validate maxLength', () => { + const schema = createLastNameValidationSchema({ maxLength: 3 }); + + let result = schema.safeParse('Doe'); + assert(result.success); + expect(result.data).toBe('Doe'); + + result = schema.safeParse('Smith'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 3, + message: 'Last name must contain at most 3 characters.', + path: [], + type: 'string', + }, + ]); + }); + + it('should validate format (non-digit)', () => { + const schema = createLastNameValidationSchema(); + + let result = schema.safeParse('Smith'); + assert(result.success); + expect(result.data).toBe('Smith'); + + result = schema.safeParse('Smith1'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Last name must not contain any digits.', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should use custom error messages', () => { + const schema = createLastNameValidationSchema({ + errorMessages: { + required_error: 'Custom required message', + invalid_type_error: 'Custom type message', + max_length_error: 'Custom max length message', + format_error: 'Custom format message', + }, + maxLength: 3, + }); + + let result = schema.safeParse(undefined); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom required message', + path: [], + received: 'undefined', + }, + ]); + + result = schema.safeParse(''); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + message: 'Custom required message', + minimum: 1, + path: [], + type: 'string', + }, + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + + result = schema.safeParse(null); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'null', + }, + ]); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'number', + }, + ]); + + result = schema.safeParse('Smith'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 3, + message: 'Custom max length message', + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse('Do3'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should correctly replace {{length}} in error messages', () => { + const maxLength = 3; + const schema = createLastNameValidationSchema({ maxLength }); + + const result = schema.safeParse('Smith'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: maxLength, + message: `Last name must contain at most ${maxLength} characters.`, + path: [], + type: 'string', + }, + ]); + }); +}); diff --git a/frontend/tests/.server/validation/name-validation-schema.test.ts b/frontend/tests/.server/validation/name-validation-schema.test.ts new file mode 100644 index 00000000..2ab20779 --- /dev/null +++ b/frontend/tests/.server/validation/name-validation-schema.test.ts @@ -0,0 +1,186 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { createNameValidationSchema } from '~/.server/validation/name-validation-schema'; + +describe('createNameValidationSchema', () => { + it('should create a basic name schema', () => { + const schema = createNameValidationSchema(); + + let result = schema.safeParse('John Doe'); + assert(result.success); + expect(result.data).toBe('John Doe'); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Name must be a string.', + path: [], + received: 'number', + }, + ]); + }); + + it('should trim whitespace by default', () => { + const schema = createNameValidationSchema(); + + const result = schema.safeParse(' John Doe '); + assert(result.success); + expect(result.data).toBe('John Doe'); + }); + + it('should validate maxLength', () => { + const schema = createNameValidationSchema({ maxLength: 5 }); + + let result = schema.safeParse('John'); + assert(result.success); + expect(result.data).toBe('John'); + + result = schema.safeParse('John Doe'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 5, + message: 'Name must contain at most 5 characters.', + path: [], + type: 'string', + }, + ]); + }); + + it('should validate format (non-digit)', () => { + const schema = createNameValidationSchema(); + + let result = schema.safeParse('John Doe'); + assert(result.success); + expect(result.data).toBe('John Doe'); + + result = schema.safeParse('John Doe 123'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Name must not contain any digits.', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should use custom error messages', () => { + const schema = createNameValidationSchema({ + errorMessages: { + required_error: 'Custom required message', + invalid_type_error: 'Custom type message', + max_length_error: 'Custom max length message', + format_error: 'Custom format message', + }, + maxLength: 5, + }); + + let result = schema.safeParse(undefined); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom required message', + path: [], + received: 'undefined', + }, + ]); + + result = schema.safeParse(''); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + message: 'Custom required message', + minimum: 1, + path: [], + type: 'string', + }, + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + + result = schema.safeParse(null); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'null', + }, + ]); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'number', + }, + ]); + + result = schema.safeParse('John Doe'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 5, + message: 'Custom max length message', + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse('John1'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should correctly replace {{length}} in error messages', () => { + const maxLength = 5; + const schema = createNameValidationSchema({ maxLength }); + + const result = schema.safeParse('abcdef'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: maxLength, + message: `Name must contain at most ${maxLength} characters.`, + path: [], + type: 'string', + }, + ]); + }); +}); diff --git a/frontend/tests/.server/validation/string-validation-schema.test.ts b/frontend/tests/.server/validation/string-validation-schema.test.ts new file mode 100644 index 00000000..05c4833f --- /dev/null +++ b/frontend/tests/.server/validation/string-validation-schema.test.ts @@ -0,0 +1,307 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { createStringValidationSchema } from '~/.server/validation'; + +describe('createStringValidationSchema', () => { + it('should create a basic string schema', () => { + const schema = createStringValidationSchema(); + + let result = schema.safeParse('test'); + assert(result.success); + expect(result.data).toBe('test'); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Expected string, received 123', + path: [], + received: 'number', + }, + ]); + }); + + it('should trim whitespace by default', () => { + const schema = createStringValidationSchema(); + + const result = schema.safeParse(' test '); + assert(result.success); + expect(result.data).toBe('test'); + }); + + it('should not trim whitespace if trim is false', () => { + const schema = createStringValidationSchema({ trim: false }); + + const result = schema.safeParse(' test '); + assert(result.success); + expect(result.data).toBe(' test '); + }); + + it('should validate minLength', () => { + const schema = createStringValidationSchema({ minLength: 3 }); + + let result = schema.safeParse('test'); + assert(result.success); + expect(result.data).toBe('test'); + + result = schema.safeParse('te'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + minimum: 3, + message: 'String must contain at least 3 characters', + path: [], + type: 'string', + }, + ]); + }); + + it('should validate maxLength', () => { + const schema = createStringValidationSchema({ maxLength: 3 }); + + let result = schema.safeParse('tes'); + assert(result.success); + expect(result.data).toBe('tes'); + + result = schema.safeParse('test'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 3, + message: 'String must contain at most 3 characters', + path: [], + type: 'string', + }, + ]); + }); + + it('should validate format with predefined patterns', () => { + const digitOnlySchema = createStringValidationSchema({ format: 'digit-only' }); + + let result = digitOnlySchema.safeParse('123'); + assert(result.success); + expect(result.data).toBe('123'); + + result = digitOnlySchema.safeParse('test'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid format', + path: [], + validation: 'regex', + }, + ]); + + const nonDigitSchema = createStringValidationSchema({ format: 'non-digit' }); + + result = nonDigitSchema.safeParse('test'); + assert(result.success); + expect(result.data).toBe('test'); + + result = nonDigitSchema.safeParse('123'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid format', + path: [], + validation: 'regex', + }, + ]); + + const alphaOnlySchema = createStringValidationSchema({ format: 'alpha-only' }); + + result = alphaOnlySchema.safeParse('test'); + assert(result.success); + expect(result.data).toBe('test'); + + result = alphaOnlySchema.safeParse('test1'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid format', + path: [], + validation: 'regex', + }, + ]); + + const alphanumericSchema = createStringValidationSchema({ format: 'alphanumeric' }); + + result = alphanumericSchema.safeParse('test1'); + assert(result.success); + expect(result.data).toBe('test1'); + + result = alphanumericSchema.safeParse('test!'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid format', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should validate format with custom regex', () => { + const schema = createStringValidationSchema({ format: /^[a-z]+$/ }); + + let result = schema.safeParse('test'); + assert(result.success); + expect(result.data).toBe('test'); + + result = schema.safeParse('TEST'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid format', + path: [], + validation: 'regex', + }, + ]); + }); + + it('should use custom error messages', () => { + const schema = createStringValidationSchema({ + errorMessages: { + required_error: 'Custom required message', + invalid_type_error: 'Custom type message', + max_length_error: 'Custom max length message', + min_length_error: 'Custom min length message', + format_error: 'Custom format message', + }, + minLength: 2, + maxLength: 4, + format: 'alpha-only', + }); + + let result = schema.safeParse(undefined); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom required message', + path: [], + received: 'undefined', + }, + ]); + + result = schema.safeParse(null); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'null', + }, + ]); + + result = schema.safeParse(123); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Custom type message', + path: [], + received: 'number', + }, + ]); + + result = schema.safeParse('test1'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 4, + message: 'Custom max length message', + path: [], + type: 'string', + }, + { + code: 'invalid_string', + message: 'Custom format message', + path: [], + validation: 'regex', + }, + ]); + + result = schema.safeParse('t'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + minimum: 2, + message: 'Custom min length message', + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse('tests'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: 4, + message: 'Custom max length message', + path: [], + type: 'string', + }, + ]); + }); + + it('should correctly replace {{length}} in error messages', () => { + const minLength = 2; + const maxLength = 5; + const schema = createStringValidationSchema({ minLength, maxLength }); + + let result = schema.safeParse('a'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_small', + exact: false, + inclusive: true, + minimum: minLength, + message: `String must contain at least ${minLength} characters`, + path: [], + type: 'string', + }, + ]); + + result = schema.safeParse('abcdef'); + assert(!result.success); + expect(result.error.errors).toStrictEqual([ + { + code: 'too_big', + exact: false, + inclusive: true, + maximum: maxLength, + message: `String must contain at most ${maxLength} characters`, + path: [], + type: 'string', + }, + ]); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 45aae0d2..e7b06643 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,6 +10,8 @@ "./e2e/**/*", "./server/**/*", "./tests/**/*", + "./tests/**/.client/**/*", + "./tests/**/.server/**/*", "./eslint.config.mjs", "./playwright.config.ts", "./postcss.config.mjs", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b2709e9b..37b12767 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,7 @@ import autoprefixer from 'autoprefixer'; import tailwindcss from 'tailwindcss'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; +import { coverageConfigDefaults } from 'vitest/config'; // important: this must be a non-aliased (ie: not ~/) import import { preserveImportMetaUrl } from './vite.server.config'; @@ -43,6 +44,11 @@ export default defineConfig({ coverage: { // Includes only files within the `app` directory for test coverage reporting. include: ['**/app/**'], + exclude: [ + '!**/app/[.]client/**', // + '!**/app/[.]server/**', + ...coverageConfigDefaults.exclude, + ], }, setupFiles: ['./tests/setup.ts'], },