diff --git a/__tests__/validation.test.ts b/__tests__/validation.test.ts new file mode 100644 index 0000000..06c989c --- /dev/null +++ b/__tests__/validation.test.ts @@ -0,0 +1,101 @@ +import mongoose from 'mongoose'; +import { isValidEmail, getEmailValidationError, createUniqueEmailValidator } from '../lib/validation'; +import User from '../models/User'; + +describe('Email Validation', () => { + // Setup MongoDB connection for unique constraint tests + beforeAll(async () => { + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/testdb'); + }); + + // Cleanup after tests + afterAll(async () => { + await mongoose.connection.close(); + }); + + // Clear database before each test + beforeEach(async () => { + await User.deleteMany({}); + }); + + // Comprehensive test cases for email formats + const validEmails = [ + 'user@example.com', + 'firstname.lastname@example.com', + 'user+tag@example.com', + 'user123@example.co.uk', + 'very.common@example.com', + 'disposable.style.email.with+symbol@example.com', + 'other.email-with-hyphen@example.com', + 'user.name+tag@example.org', + ]; + + const invalidEmails = [ + '', + ' ', + 'invalid-email', + 'user@', + '@example.com', + 'user@example', + 'user@.com', + 'user@example.', + 'user@example..com', + 'a@b.c', // Too short + 'a' .repeat(65) + '@example.com', // Local part too long + 'user@' + 'a'.repeat(254) + '.com', // Domain too long + ]; + + // Validate email format tests + describe('isValidEmail - Format Validation', () => { + validEmails.forEach(email => { + it(`should return true for valid email: ${email}`, () => { + expect(isValidEmail(email)).toBe(true); + }); + }); + + invalidEmails.forEach(email => { + it(`should return false for invalid email: ${email}`, () => { + expect(isValidEmail(email)).toBe(false); + }); + }); + }); + + // Error message generation tests + describe('getEmailValidationError', () => { + it('should return error for empty email', () => { + expect(getEmailValidationError('')).toBe('Email address is required'); + expect(getEmailValidationError(' ')).toBe('Email address is required'); + }); + + it('should return error for invalid email formats', () => { + expect(getEmailValidationError('invalid-email')).toBe('Please enter a valid email address (e.g., example@domain.com)'); + }); + + it('should return null for valid email', () => { + expect(getEmailValidationError('user@example.com')).toBeNull(); + }); + }); + + // Unique constraint tests + describe('Unique Email Constraint', () => { + it('should prevent duplicate email registration (case-insensitive)', async () => { + // Create first user + const user1 = new User({ email: 'test@example.com' }); + await user1.save(); + + // Attempt to create user with same email (different case) + const user2 = new User({ email: 'TEST@EXAMPLE.COM' }); + + // Expect an error about duplicate email + await expect(user2.save()).rejects.toThrow('Email already in use'); + }); + + it('should allow unique email registration', async () => { + const user1 = new User({ email: 'unique1@example.com' }); + const user2 = new User({ email: 'unique2@example.com' }); + + await expect(user1.save()).resolves.toBeTruthy(); + await expect(user2.save()).resolves.toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..4a42fe0 --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,71 @@ +import mongoose from 'mongoose'; + +/** + * Validates email format according to RFC 5322 standard + * @param email - Email address to validate + * @returns boolean indicating if the email is valid + */ +export const isValidEmail = (email: string): boolean => { + // Trim and convert to lowercase for consistent validation + if (!email) return false; + const trimmedEmail = email.trim().toLowerCase(); + + // Comprehensive RFC 5322 email validation regex + 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])?)*$/; + + // Validate email format and length + if ( + trimmedEmail.length < 5 || + trimmedEmail.length > 254 || + !emailRegex.test(trimmedEmail) + ) { + return false; + } + + // Additional checks for email parts + const [local, domain] = trimmedEmail.split('@'); + return ( + local.length <= 64 && + domain.length <= 253 && + domain.split('.').every(part => part.length <= 63) + ); +}; + +/** + * Generates a descriptive error message for email validation + * @param email - Email address to validate + * @returns Error message or null if email is valid + */ +export const getEmailValidationError = (email: string): string | null => { + if (!email || email.trim().length === 0) { + return 'Email address is required'; + } + + if (!isValidEmail(email)) { + return 'Please enter a valid email address (e.g., example@domain.com)'; + } + + return null; +}; + +/** + * Creates a unique email validator for Mongoose + * @param model - Mongoose model to check against + * @returns Async validation function + */ +export const createUniqueEmailValidator = (model: mongoose.Model) => { + return async function(email: string): Promise { + // Validate email format first + if (!isValidEmail(email)) { + throw new Error('Invalid email format'); + } + + // Perform case-insensitive unique check + const existingUser = await model.findOne({ + email: { $regex: new RegExp(`^${email}$`, 'i') } + }); + + // Return true if no existing user found + return !existingUser; + }; +}; \ No newline at end of file diff --git a/models/User.ts b/models/User.ts new file mode 100644 index 0000000..900e0f7 --- /dev/null +++ b/models/User.ts @@ -0,0 +1,70 @@ +import mongoose from 'mongoose'; +import { isValidEmail } from '../lib/validation'; + +// Define the User schema interface +interface IUser extends mongoose.Document { + email: string; + createdAt: Date; + updatedAt: Date; +} + +// Create the User schema with unique, case-insensitive email validation +const UserSchema = new mongoose.Schema({ + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + lowercase: true, // Store emails in lowercase + trim: true, + validate: { + validator: function(value: string) { + return isValidEmail(value); + }, + message: 'Invalid email format' + } + } +}, { + timestamps: true, // Automatically add createdAt and updatedAt + // Ensure case-insensitive unique index + autoIndex: true +}); + +// Create a pre-save middleware to ensure email uniqueness +UserSchema.pre('save', async function(next) { + // Only run this check if email is modified + if (!this.isModified('email')) return next(); + + try { + // Check for existing user with the same email (case-insensitive) + const existingUser = await this.constructor.findOne({ + email: this.email + }); + + if (existingUser && existingUser._id.toString() !== this._id.toString()) { + next(new Error('Email already in use')); + } else { + next(); + } + } catch (error) { + next(error as mongoose.CallbackError); + } +}); + +// Create a compound unique index for case-insensitive email +UserSchema.index({ email: 1 }, { + unique: true, + collation: { locale: 'en', strength: 2 } +}); + +// Ensure email is lowercase before saving +UserSchema.pre('save', function(next) { + if (this.isModified('email')) { + this.email = this.email.toLowerCase(); + } + next(); +}); + +// 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/types.d.ts b/types.d.ts index a2748eb..c26df11 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,109 +1,22 @@ -/* eslint-disable no-unused-vars */ -type Job = { - data: [ - { - employer_name: string; - employer_logo: string; - employer_website: string; - employer_company_type: string; - job_publisher: string; - job_id: string; - job_employment_type: string; - job_title: string; - job_apply_link: string; - job_apply_is_direct: boolean; - job_apply_quality_score: number; - job_description: string; - job_is_remote: boolean; - job_posted_at_timestamp: number; - job_posted_at_datetime_utc: string; - job_city: string; - job_state: string; - job_country: string; - job_latitude: number; - job_longitude: number; - job_benefits: null; - job_google_link: string; - job_offer_expiration_datetime_utc: string; - job_offer_expiration_timestamp: number; - job_required_experience: { - no_experience_required: boolean; - required_experience_in_months: null; - experience_mentioned: boolean; - experience_preferred: boolean; - }; - job_required_skills: null; - job_required_education: { - postgraduate_degree: boolean; - professional_certification: boolean; - high_school: boolean; - associates_degree: boolean; - bachelors_degree: boolean; - degree_mentioned: boolean; - degree_preferred: boolean; - professional_certification_mentioned: boolean; - }; - job_experience_in_place_of_education: boolean; - job_min_salary: null; - job_max_salary: null; - job_salary_currency: null; - job_salary_period: null; - job_highlights: { - Qualifications: [string]; - Responsibilities: [string, string]; - Benefits: [string, string, string]; - }; - job_job_title: null; - job_posting_language: string; - job_onet_soc: string; - job_onet_job_zone: string; - job_naics_code: string; - job_naics_name: string; - }, - ]; -}; +import mongoose from 'mongoose'; -type JobResult = Job["data"][0]; +declare module 'mongoose' { + interface EmailValidationOptions { + message?: string; + } -type JobSalaryEntryType = { - location?: string; - job_title?: string; - publisher_name?: string; - publisher_link?: string; - min_salary?: number; - max_salary?: number; - median_salary?: number; - salary_period?: "YEAR"; - salary_currency?: "CAD"; -}; -interface JobRequiredExperienceType { - no_experience_required?: boolean; - required_experience_in_months: number | null; - experience_mentioned?: boolean; - experience_preferred?: boolean; + interface SchemaTypeOptions { + /** + * Custom email validation for mongoose schemas + */ + email?: boolean | EmailValidationOptions; + } } -interface SearchParams { - query?: string; - searchQuery?: string; - employmentType?: string; - pageNumber?: number; - recommended?: boolean; -} - -interface CommonFilterDataShapeType { - name: string; - value: string; -} - -type CompanyDetailCardProps = { - logo: string; - employerName: string; - companyType: string; - city: string; - state: string; - companyLink: string | null; - jobId: string; - companyJobs: JobResult[]; - country: string; -}; +/** + * Email validation interface for user registration + */ +export interface IUserEmail { + email: string; + isValidEmail: (email: string) => boolean; +} \ No newline at end of file