Skip to content
101 changes: 101 additions & 0 deletions __tests__/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
71 changes: 71 additions & 0 deletions lib/validation.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => {
return async function(email: string): Promise<boolean> {
// 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;
};
};
70 changes: 70 additions & 0 deletions models/User.ts
Original file line number Diff line number Diff line change
@@ -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<IUser>({
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<IUser>('User', UserSchema);

export default User;
123 changes: 18 additions & 105 deletions types.d.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
/**
* 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;
}