diff --git a/__tests__/RegisterForm.test.tsx b/__tests__/RegisterForm.test.tsx new file mode 100644 index 0000000..12762f6 --- /dev/null +++ b/__tests__/RegisterForm.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import RegisterForm from '../components/RegisterForm'; + +describe('RegisterForm', () => { + test('renders email input', () => { + render(); + const emailInput = screen.getByLabelText(/email/i); + expect(emailInput).toBeInTheDocument(); + }); + + test.each([ + 'invalid-email', + '@example.com', + 'user@', + 'user@example', + 'a'.repeat(255) + '@example.com' + ])('shows error for invalid email: %s', async (invalidEmail) => { + render(); + const emailInput = screen.getByLabelText(/email/i); + const submitButton = screen.getByText(/register/i); + + fireEvent.change(emailInput, { target: { value: invalidEmail } }); + fireEvent.click(submitButton); + + await waitFor(() => { + const errorMessage = screen.getByText(/please enter a valid email address/i); + expect(errorMessage).toBeInTheDocument(); + }); + }); + + test.each([ + 'test@example.com', + 'user.name@example.co.uk', + 'firstname+lastname@example.com' + ])('accepts valid email: %s', async (validEmail) => { + render(); + const emailInput = screen.getByLabelText(/email/i); + const submitButton = screen.getByText(/register/i); + + // Mock console.log to check registration + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + fireEvent.change(emailInput, { target: { value: validEmail } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Registration with normalized email'), + expect.any(String) + ); + }); + + consoleSpy.mockRestore(); + }); + + test('handles case-insensitive email input', async () => { + render(); + const emailInput = screen.getByLabelText(/email/i); + const submitButton = screen.getByText(/register/i); + + // Mock console.log to check registration + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + fireEvent.change(emailInput, { target: { value: 'Test@Example.COM' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Registration with normalized email'), + 'test@example.com' + ); + }); + + consoleSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/__tests__/user-validation.test.ts b/__tests__/user-validation.test.ts new file mode 100644 index 0000000..a6a7873 --- /dev/null +++ b/__tests__/user-validation.test.ts @@ -0,0 +1,71 @@ +import mongoose from 'mongoose'; +import { isEmailUnique, validateEmailUniqueness } from '../lib/user-validation'; +import User from '../models/User'; + +// Mock MongoDB connection +beforeAll(async () => { + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/testdb'); +}); + +// Clean up and close connection after tests +afterAll(async () => { + await mongoose.connection.dropDatabase(); + await mongoose.connection.close(); +}); + +// Clear users before each test +beforeEach(async () => { + await User.deleteMany({}); +}); + +describe('Email Uniqueness Validation', () => { + test('should return true for a new email', async () => { + const uniqueEmail = 'newuser@example.com'; + const result = await isEmailUnique(uniqueEmail); + expect(result).toBe(true); + }); + + test('should return false for an existing email', async () => { + // Create a user first + await User.create({ + email: 'existing@example.com', + password: 'password123' + }); + + const result = await isEmailUnique('existing@example.com'); + expect(result).toBe(false); + }); + + test('should be case-insensitive for email uniqueness', async () => { + // Create a user with lowercase email + await User.create({ + email: 'test@example.com', + password: 'password123' + }); + + // Check with different cases + const result1 = await isEmailUnique('TEST@EXAMPLE.COM'); + const result2 = await isEmailUnique('Test@Example.com'); + + expect(result1).toBe(false); + expect(result2).toBe(false); + }); + + test('validateEmailUniqueness should provide detailed result', async () => { + // Create an existing user + await User.create({ + email: 'existing@example.com', + password: 'password123' + }); + + // Test unique email + const uniqueResult = await validateEmailUniqueness('newuser@example.com'); + expect(uniqueResult.isUnique).toBe(true); + expect(uniqueResult.message).toBeUndefined(); + + // Test existing email + const existingResult = await validateEmailUniqueness('existing@example.com'); + expect(existingResult.isUnique).toBe(false); + expect(existingResult.message).toBe('This email is already registered'); + }); +}); \ No newline at end of file diff --git a/__tests__/validation.test.ts b/__tests__/validation.test.ts new file mode 100644 index 0000000..67d38ca --- /dev/null +++ b/__tests__/validation.test.ts @@ -0,0 +1,85 @@ +import { isValidEmail, normalizeEmail, getEmailValidationError } from '../lib/validation'; + +describe('Email Validation', () => { + // Valid email test cases covering various formats + const validEmails = [ + 'user@example.com', + 'firstname.lastname@example.com', + 'email@subdomain.example.com', + 'firstname+lastname@example.com', + 'email@123.123.123.123', + '1234567890@example.com', + 'email@example-one.com', + '_______@example.com', + 'email@example.name', + 'email@example.museum', + 'email@example.co.jp', + 'very.common@example.com', + 'disposable.style.email@example.com', + 'other.email-with-hyphen@example.com', + 'fully-qualified-domain@example.com', + // Test IP and domain variations + 'user@[123.123.123.123]', + 'user@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]' + ]; + + // Invalid email test cases + const invalidEmails = [ + '', + ' ', + 'plainaddress', + '@example.com', + 'Joe Smith ', + 'email.example.com', + 'email@example@example.com', + '.email@example.com', + 'email.@example.com', + 'email..email@example.com', + 'email@example.com (Joe Smith)', + 'email@example', + 'email@-example.com', + 'email@example..com', + // Emails exceeding 254 characters + 'a'.repeat(255) + '@example.com' + ]; + + // Test valid email validation + validEmails.forEach(email => { + test(`Valid email: ${email}`, () => { + expect(isValidEmail(email)).toBe(true); + }); + }); + + // Test invalid email validation + invalidEmails.forEach(email => { + test(`Invalid email: ${email}`, () => { + expect(isValidEmail(email)).toBe(false); + }); + }); + + // Test email normalization + describe('Email Normalization', () => { + test('Normalize email to lowercase', () => { + expect(normalizeEmail('Test@Example.COM')).toBe('test@example.com'); + }); + + test('Trim whitespace in email', () => { + expect(normalizeEmail(' test@example.com ')).toBe('test@example.com'); + }); + }); + + // Test error message generation + describe('Email Validation Error Messages', () => { + test('Empty email error message', () => { + expect(getEmailValidationError('')).toBe('Email cannot be empty'); + }); + + test('Too long email error message', () => { + expect(getEmailValidationError('a'.repeat(255) + '@example.com')).toBe('Email address is too long'); + }); + + test('Invalid email error message', () => { + expect(getEmailValidationError('invalid-email')).toBe('Please enter a valid email address'); + }); + }); +}); \ No newline at end of file diff --git a/components/RegisterForm.tsx b/components/RegisterForm.tsx new file mode 100644 index 0000000..a98e805 --- /dev/null +++ b/components/RegisterForm.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { isValidEmail, normalizeEmail, getEmailValidationError } from '../lib/validation'; +import { validateEmailUniqueness } from '../lib/user-validation'; + +interface RegisterFormData { + email: string; + password: string; +} + +const RegisterForm: React.FC = () => { + const [emailError, setEmailError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + clearErrors + } = useForm(); + + const validateEmail = async (email: string): Promise => { + const normalizedEmail = normalizeEmail(email); + + // Clear previous errors + setEmailError(null); + clearErrors('email'); + + // Check email format + if (!isValidEmail(normalizedEmail)) { + const errorMessage = getEmailValidationError(email); + setEmailError(errorMessage); + setError('email', { + type: 'manual', + message: errorMessage + }); + return false; + } + + // Check email uniqueness + try { + const uniquenessResult = await validateEmailUniqueness(normalizedEmail); + + if (!uniquenessResult.isUnique) { + setEmailError(uniquenessResult.message || 'Email is already registered'); + setError('email', { + type: 'manual', + message: uniquenessResult.message || 'Email is already registered' + }); + return false; + } + } catch (error) { + setEmailError('Error validating email'); + return false; + } + + return true; + }; + + const onSubmit = async (data: RegisterFormData) => { + setIsSubmitting(true); + + try { + const isValid = await validateEmail(data.email); + + if (isValid) { + const normalizedEmail = normalizeEmail(data.email); + console.log('Registration with normalized email:', normalizedEmail); + // Add actual registration logic here + } + } catch (error) { + console.error('Registration error:', error); + setEmailError('Registration failed'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + Email + + {(emailError || errors.email) && ( + + {emailError || errors.email?.message} + + )} + + {/* Other form fields would be added here */} + + {isSubmitting ? 'Registering...' : 'Register'} + + + ); +}; + +export default RegisterForm; \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..331666c --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; \ No newline at end of file diff --git a/lib/user-validation.ts b/lib/user-validation.ts new file mode 100644 index 0000000..796475b --- /dev/null +++ b/lib/user-validation.ts @@ -0,0 +1,47 @@ +import { normalizeEmail } from './validation'; +import User from '../models/User'; + +/** + * Check if an email is already registered + * @param email Email to check for uniqueness + * @returns Promise resolving to boolean indicating email uniqueness + */ +export const isEmailUnique = async (email: string): Promise => { + try { + const normalizedEmail = normalizeEmail(email); + const existingUser = await User.findOne({ email: normalizedEmail }); + return !existingUser; + } catch (error) { + console.error('Error checking email uniqueness:', error); + return false; + } +}; + +/** + * Validate email uniqueness with detailed error handling + * @param email Email to validate + * @returns Validation result with message + */ +export const validateEmailUniqueness = async (email: string): Promise<{ + isUnique: boolean; + message?: string; +}> => { + try { + const isUnique = await isEmailUnique(email); + + if (!isUnique) { + return { + isUnique: false, + message: 'This email is already registered' + }; + } + + return { isUnique: true }; + } catch (error) { + console.error('Email uniqueness validation error:', error); + return { + isUnique: false, + message: 'Error validating email uniqueness' + }; + } +}; \ No newline at end of file diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..586c3f7 --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,45 @@ +/** + * Validates an email address using a comprehensive RFC 5322 compliant regex + * @param email The email address to validate + * @returns Boolean indicating if the email is valid + */ +export const isValidEmail = (email: string): boolean => { + // Comprehensive RFC 5322 email validation regex + const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + + // Trim and validate email + const trimmedEmail = email.trim(); + + // Check length, format, and basic structure + return trimmedEmail.length > 0 && + trimmedEmail.length <= 254 && + emailRegex.test(trimmedEmail); +}; + +/** + * Normalize email for case-insensitive comparison + * @param email The email to normalize + * @returns Normalized email address + */ +export const normalizeEmail = (email: string): string => { + return email.trim().toLowerCase(); +}; + +/** + * Generate a user-friendly email validation error message + * @param email The email address that failed validation + * @returns Descriptive error message + */ +export const getEmailValidationError = (email: string): string => { + const trimmedEmail = email.trim(); + + if (trimmedEmail.length === 0) { + return "Email cannot be empty"; + } + + if (trimmedEmail.length > 254) { + return "Email address is too long"; + } + + return "Please enter a valid email address"; +}; \ No newline at end of file diff --git a/models/User.ts b/models/User.ts new file mode 100644 index 0000000..a565942 --- /dev/null +++ b/models/User.ts @@ -0,0 +1,68 @@ +import mongoose from 'mongoose'; +import { normalizeEmail } from '../lib/validation'; + +// Define the User interface +export interface IUser extends mongoose.Document { + email: string; + password: string; + createdAt: Date; + updatedAt: Date; +} + +// Create the User schema +const UserSchema = new mongoose.Schema({ + email: { + type: String, + required: [true, 'Email is required'], + unique: true, // Database-level unique constraint + lowercase: true, // Store email in lowercase + trim: true, // Remove whitespace + validate: { + validator: function(value: string) { + // Reuse existing email validation + const { isValidEmail } = require('../lib/validation'); + return isValidEmail(value); + }, + message: 'Please provide a valid email address' + }, + index: true // Create an index for faster querying + }, + password: { + type: String, + required: [true, 'Password is required'], + minlength: [8, 'Password must be at least 8 characters long'] + }, + createdAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true, // Automatically manage createdAt and updatedAt + // Ensure unique constraint is case-insensitive + autoIndex: true +}); + +// Pre-save middleware to normalize email +UserSchema.pre('save', function(next) { + // Normalize email before saving + if (this.isModified('email')) { + this.email = normalizeEmail(this.email); + } + next(); +}); + +// Custom method to check if email exists +UserSchema.statics.emailExists = async function(email: string): Promise { + const normalizedEmail = normalizeEmail(email); + const existingUser = await this.findOne({ email: normalizedEmail }); + return !!existingUser; +}; + +// Create and export the User model +const User = mongoose.models.User || mongoose.model('User', UserSchema); + +export default User; \ No newline at end of file diff --git a/package.json b/package.json index 5f77db5..b06b96a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "jest", + "test:watch": "jest --watch", "ionic:build": "npm run build", "ionic:serve": "npm run start" }, @@ -60,6 +62,10 @@ "zod": "^3.21.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@types/jest": "^29.5.12", + "@types/testing-library__jest-dom": "^6.0.0", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.12.0", @@ -72,8 +78,22 @@ "eslint-plugin-n": "^16.3.1", "eslint-plugin-promise": "^6.1.1", "eslint-plugin-react": "^7.32.2", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "prettier": "^3.0.0", "prettier-plugin-tailwindcss": "^0.4.0", + "ts-jest": "^29.1.2", "typescript": "^5.1.6" + }, + "jest": { + "testEnvironment": "jsdom", + "setupFilesAfterEnv": ["/jest.setup.js"], + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "moduleNameMapper": { + "\\.(css|less|sass|scss)$": "identity-obj-proxy" + } } -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 7a7ed11..a23f5f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,14 +21,16 @@ ], "paths": { "@/*": ["./*"] - } + }, + "types": ["jest", "node"] }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "components/index.js" + "components/index.js", + "__tests__/**/*.test.ts" ], "exclude": ["node_modules"] -} +} \ No newline at end of file
+ {emailError || errors.email?.message} +