diff --git a/__tests__/validation.test.ts b/__tests__/validation.test.ts new file mode 100644 index 0000000..b82e433 --- /dev/null +++ b/__tests__/validation.test.ts @@ -0,0 +1,78 @@ +import { validateEmail, sanitizeEmail, getEmailValidationError } from '../lib/validation'; + +describe('Email Validation', () => { + // Valid email test cases + const validEmails = [ + 'user@example.com', + 'john.doe@example.co.uk', + 'john+tag@example.com', + 'user-name@example.org', + 'user123@example.net', + 'first.last@example.com', + 'email@subdomain.example.com', + 'very.common@example.com', + 'disposable.style.email@example.com', + 'other.email-with-hyphen@example.com' + ]; + + // Invalid email test cases + const invalidEmails = [ + '', + ' ', + 'invalid-email', + 'invalid@email', + '@missingusername.com', + 'username@.com', + 'username@domain', + 'username@domain.', + 'username@-domain.com', + 'a'.repeat(321) + '@example.com', // Exceed max length + 'email@111.222.333.44444', // Invalid IP domain + 'email@[123.123.123.123]', // IP in square brackets + 'much."more unusual"@example.com', // Unusual but invalid local part + 'admin@mailserver1' // Missing top-level domain + ]; + + // Test valid emails + test.each(validEmails)('validates valid email: %s', (email) => { + expect(validateEmail(email)).toBe(true); + expect(getEmailValidationError(email)).toBeNull(); + }); + + // Test invalid emails + test.each(invalidEmails)('invalidates invalid email: %s', (email) => { + expect(validateEmail(email)).toBe(false); + expect(getEmailValidationError(email)).not.toBeNull(); + }); + + // Sanitization tests + describe('Email Sanitization', () => { + test('trims whitespace', () => { + expect(sanitizeEmail(' test@example.com ')).toBe('test@example.com'); + }); + + test('converts to lowercase', () => { + expect(sanitizeEmail('Test@Example.COM')).toBe('test@example.com'); + }); + + test('handles empty input', () => { + expect(sanitizeEmail('')).toBe(''); + }); + }); + + // Specific error message tests + describe('Email Validation Error Messages', () => { + test('returns error for empty email', () => { + expect(getEmailValidationError('')).toBe('Email is required'); + }); + + test('returns error for short email', () => { + expect(getEmailValidationError('a@b')).toBe('Invalid email format'); + }); + + test('returns error for extremely long email', () => { + const longEmail = 'a'.repeat(322) + '@example.com'; + expect(getEmailValidationError(longEmail)).toBe('Email is too long'); + }); + }); +}); \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..b832999 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,2 @@ +// Add any global Jest configuration or setup here +// For now, it's an empty file, but it can be expanded later \ No newline at end of file diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..058be04 --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,69 @@ +/** + * Comprehensive email validation function + * @param email - Email address to validate + * @returns boolean indicating if the email is valid + */ +export const validateEmail = (email: string): boolean => { + // If email is undefined or null, return false + if (!email) return false; + + // Trim and convert to lowercase + const trimmedEmail = email.trim().toLowerCase(); + + // Check length constraints + if (trimmedEmail.length < 5 || trimmedEmail.length > 320) return false; + + // Comprehensive email validation regex + // Follows RFC 5322 standard with some practical constraints + const emailRegex = /^[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])?)*$/; + + // Additional checks + const [localPart, domainPart] = trimmedEmail.split('@'); + + // Validate local and domain parts + if (!localPart || !domainPart) return false; + if (localPart.length > 64 || domainPart.length > 255) return false; + + // Must contain at least one dot in domain + if (!domainPart.includes('.')) return false; + + // Final regex test + return emailRegex.test(trimmedEmail); +}; + +/** + * Sanitize email by trimming and converting to lowercase + * @param email - Email address to sanitize + * @returns sanitized email + */ +export const sanitizeEmail = (email: string): string => { + return email ? email.trim().toLowerCase() : ''; +}; + +/** + * Get detailed email validation error + * @param email - Email to validate + * @returns Error message or null if valid + */ +export const getEmailValidationError = (email: string): string | null => { + if (!email) return 'Email is required'; + + const trimmedEmail = email.trim().toLowerCase(); + + if (trimmedEmail.length < 5) return 'Email is too short'; + if (trimmedEmail.length > 320) return 'Email is too long'; + + if (!trimmedEmail.includes('@')) return 'Email must contain @ symbol'; + + const [localPart, domainPart] = trimmedEmail.split('@'); + + if (!localPart) return 'Email is missing local part'; + if (!domainPart) return 'Email is missing domain part'; + + if (localPart.length > 64) return 'Local part of email is too long'; + if (domainPart.length > 255) return 'Domain part of email is too long'; + + if (!domainPart.includes('.')) return 'Domain must contain at least one dot'; + + return validateEmail(email) ? null : 'Invalid email format'; +}; \ No newline at end of file diff --git a/models/User.ts b/models/User.ts new file mode 100644 index 0000000..13136c8 --- /dev/null +++ b/models/User.ts @@ -0,0 +1,51 @@ +import mongoose from 'mongoose'; +import { validateEmail } from '../lib/validation'; + +// Define interface for type safety +interface IUser extends mongoose.Document { + email: string; +} + +const UserSchema = new mongoose.Schema({ + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + trim: true, + lowercase: true, + validate: { + validator: function(v: string) { + return validateEmail(v); + }, + message: 'Invalid email format' + } + } +}, { + timestamps: true +}); + +// Create a compound unique index to ensure case-insensitive unique constraint +UserSchema.index({ email: 1 }, { + unique: true, + collation: { locale: 'en', strength: 2 } +}); + +// Pre-save middleware to normalize email +UserSchema.pre('save', function(next) { + if (this.isModified('email')) { + this.email = this.email.trim().toLowerCase(); + } + next(); +}); + +// Handle duplicate key error with a more informative message +UserSchema.post('save', function(error: any, doc: any, next: any) { + if (error.name === 'MongoServerError' && error.code === 11000) { + next(new Error('An account with this email already exists')); + } else { + next(error); + } +}); + +// Create the model, avoiding re-compilation +export const User = mongoose.models.User || mongoose.model('User', UserSchema); \ No newline at end of file diff --git a/package.json b/package.json index 5f77db5..eae27c0 100644 --- a/package.json +++ b/package.json @@ -7,73 +7,35 @@ "build": "next build", "start": "next start", "lint": "next lint", - "ionic:build": "npm run build", - "ionic:serve": "npm run start" + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { - "@hookform/resolvers": "^3.1.1", - "@radix-ui/react-accordion": "^1.1.2", - "@radix-ui/react-avatar": "^1.0.3", - "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-collapsible": "^1.0.3", - "@radix-ui/react-dialog": "^1.0.4", - "@radix-ui/react-dropdown-menu": "^2.0.5", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-popover": "^1.0.6", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-toast": "^1.1.4", - "@reduxjs/toolkit": "^1.9.5", - "@types/node": "20.4.1", - "@types/react": "18.2.14", - "@types/react-dom": "18.2.6", - "autoprefixer": "10.4.14", - "chart.js": "^4.4.1", - "class-variance-authority": "^0.6.1", - "clsx": "^2.0.0", - "cookies-next": "^4.1.1", - "date-fns": "^2.30.0", - "eslint-config-next": "14.0.3", - "eslint-config-standard": "^17.1.0", - "eslint-plugin-tailwindcss": "^3.14.1", - "i": "^0.3.7", - "lucide-react": "^0.259.0", - "moment": "^2.29.4", + "mongoose": "^7.6.3", "next": "14.0.3", - "next-themes": "^0.2.1", - "postcss": "8.4.25", "react": "18.2.0", - "react-apexcharts": "^1.4.1", - "react-chartjs-2": "^5.2.0", - "react-day-picker": "^8.8.0", - "react-dom": "18.2.0", - "react-hook-form": "^7.45.1", - "react-icons": "^5.0.1", - "react-redux": "^8.1.1", - "redux-persist": "^6.0.0", - "tailwind-merge": "^1.13.2", - "tailwindcss": "3.4.1", - "tailwindcss-animate": "^1.0.6", - "uuid": "^9.0.1", "zod": "^3.21.4" }, "devDependencies": { - "@types/uuid": "^9.0.8", - "@typescript-eslint/eslint-plugin": "^6.12.0", - "@typescript-eslint/parser": "^6.12.0", - "eslint": "^8.44.0", - "eslint-config-prettier": "^9.0.0", - "eslint-config-standard-with-typescript": "^40.0.0", - "eslint-config-xo": "^0.43.1", - "eslint-config-xo-typescript": "^1.0.1", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-n": "^16.3.1", - "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-react": "^7.32.2", - "prettier": "^3.0.0", - "prettier-plugin-tailwindcss": "^0.4.0", + "@types/jest": "^29.5.11", + "@types/mongoose": "^5.11.97", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", "typescript": "^5.1.6" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ] } -} +} \ No newline at end of file