diff --git a/__tests__/email-validation.test.ts b/__tests__/email-validation.test.ts new file mode 100644 index 0000000..42c4a62 --- /dev/null +++ b/__tests__/email-validation.test.ts @@ -0,0 +1,83 @@ +import { EmailValidator, EmailUniquenessChecker } from '../lib/email-validation'; + +describe('Email Validation', () => { + // Valid email test cases + const validEmails = [ + 'user@example.com', + 'first.last@example.co.uk', + 'user+tag@example.org', + 'user123@example-domain.com', + 'very.common@example.com', + 'disposable.style.email.with+symbol@example.com', + 'other.email-with-hyphen@example.com', + 'fully-qualified-domain@example.com', + 'user.name+tag@example.org', + 'x@example.com', // Shortest possible valid email + ]; + + validEmails.forEach(email => { + test(`should validate valid email: ${email}`, () => { + expect(EmailValidator.validate(email)).toBe(true); + expect(EmailValidator.getValidationError(email)).toBeNull(); + }); + }); + + // Invalid email test cases + const invalidEmails = [ + '', + ' ', + 'invalid-email', + 'user@', + '@domain.com', + 'user@domain', + 'user@.com', + 'user@domain..com', + 'a'.repeat(321) + '@example.com', // Too long + 'user@' + 'a'.repeat(256) + '.com', + 'invalid@domain', + 'invalid@domain.', + 'invalid@.domain', + 'invalid@domain..com', + 'invalid@-domain.com', + 'invalid@domain-.com', + ]; + + invalidEmails.forEach(email => { + test(`should invalidate invalid email: ${email}`, () => { + expect(EmailValidator.validate(email)).toBe(false); + expect(EmailValidator.getValidationError(email)).not.toBeNull(); + }); + }); + + // Email normalization and case-insensitivity tests + describe('Email Normalization', () => { + test('should normalize emails to lowercase', () => { + expect(EmailValidator.normalize('User@Example.COM')).toBe('user@example.com'); + }); + + test('should trim whitespace', () => { + expect(EmailValidator.normalize(' user@example.com ')).toBe('user@example.com'); + }); + }); + + // Email uniqueness tests + describe('Email Uniqueness', () => { + beforeEach(async () => { + // Reset uniqueness checker before each test + const registeredEmail = 'existing@example.com'; + await EmailUniquenessChecker.registerEmail(registeredEmail); + }); + + test('should detect non-unique email (case-insensitive)', async () => { + const existingEmail = 'Existing@Example.COM'; + const uniqueResult = await EmailUniquenessChecker.isUnique(existingEmail); + expect(uniqueResult).toBe(false); + }); + + test('should allow unique email', async () => { + const uniqueEmail = 'new.unique@example.com'; + const uniqueResult = await EmailUniquenessChecker.isUnique(uniqueEmail); + expect(uniqueResult).toBe(true); + }); + }); +}); diff --git a/lib/email-validation.ts b/lib/email-validation.ts new file mode 100644 index 0000000..c63df75 --- /dev/null +++ b/lib/email-validation.ts @@ -0,0 +1,100 @@ +/** + * Comprehensive email validation utility + */ +export class EmailValidator { + /** + * Advanced email validation regex + * Supports most standard email formats with additional constraints + */ + private static EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + /** + * Validate email format + * @param email - Email address to validate + * @returns boolean indicating if email is valid + */ + static validate(email: string): boolean { + // Check if email is empty or undefined + if (!email) return false; + + // Trim and convert to lowercase + const trimmedEmail = email.trim().toLowerCase(); + + // Length checks + if (trimmedEmail.length < 5 || trimmedEmail.length > 320) return false; + + // Regex validation + if (!this.EMAIL_REGEX.test(trimmedEmail)) return false; + + // Split email into local and domain parts + const [localPart, domainPart] = trimmedEmail.split('@'); + + // Additional checks for local and domain parts + if (localPart.length > 64 || domainPart.length > 255) return false; + + // Ensure valid top-level domain + const domainParts = domainPart.split('.'); + if (domainParts.length < 2) return false; + const tld = domainParts[domainParts.length - 1]; + if (tld.length < 2 || tld.length > 63) return false; + + return true; + } + + /** + * Generate descriptive error message for invalid emails + * @param email - Email address to validate + * @returns Error message or null if email is valid + */ + static getValidationError(email: string): string | null { + if (!email) return 'Email cannot be empty'; + + const trimmedEmail = email.trim().toLowerCase(); + + if (trimmedEmail.length < 5) return 'Email is too short'; + if (trimmedEmail.length > 320) return 'Email is too long'; + + if (!this.validate(trimmedEmail)) { + return 'Please enter a valid email address'; + } + + return null; + } + + /** + * Normalize email for case-insensitive comparison + * @param email - Email to normalize + * @returns Normalized email + */ + static normalize(email: string): string { + return email.trim().toLowerCase(); + } +} + +/** + * Email uniqueness checker (mock implementation) + * In a real application, this would interact with the database + */ +export class EmailUniquenessChecker { + // Simulated email storage (would be replaced by database check) + private static registeredEmails: Set = new Set(); + + /** + * Check if email is unique + * @param email - Email to check + * @returns boolean indicating if email is unique + */ + static async isUnique(email: string): Promise { + const normalizedEmail = EmailValidator.normalize(email); + return !this.registeredEmails.has(normalizedEmail); + } + + /** + * Register an email (simulating database insertion) + * @param email - Email to register + */ + static async registerEmail(email: string): Promise { + const normalizedEmail = EmailValidator.normalize(email); + this.registeredEmails.add(normalizedEmail); + } +}