Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions __tests__/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
2 changes: 2 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions lib/validation.ts
Original file line number Diff line number Diff line change
@@ -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';
};
51 changes: 51 additions & 0 deletions models/User.ts
Original file line number Diff line number Diff line change
@@ -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<IUser>({
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<IUser>('User', UserSchema);
84 changes: 23 additions & 61 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
}