diff --git a/__tests__/email-validation.test.ts b/__tests__/email-validation.test.ts new file mode 100644 index 0000000..228d850 --- /dev/null +++ b/__tests__/email-validation.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { isValidEmail, normalizeEmail } from '../lib/utils'; + +describe('Comprehensive Email Validation', () => { + // Valid email test cases covering various scenarios + const validEmails = [ + // Standard formats + 'user@example.com', + 'firstname.lastname@example.com', + 'user+tag@example.com', + 'user123@example.co.uk', + + // Quoted local parts + '"john.smith"@example.com', + '"very.common"@example.com', + '"very,unusual"@example.com', + + // Subdomains and complex domains + 'user@subdomain.example.com', + 'user@example.co.uk', + + // Special characters in local part + 'firstname+lastname@example.com', + 'user.name@example.com', + + // IP address domains + 'user@[192.168.0.1]', + 'user@[IPv6:2001:db8::1]' + ]; + + // Invalid email test cases covering various error scenarios + const invalidEmails = [ + // Empty or invalid inputs + '', + ' ', + 'invalid-email', + 'user@.com', + '@example.com', + + // Incomplete domains + 'user@example', + 'user@example..com', + 'user@-example.com', + + // Excessive length + 'a'.repeat(65) + '@example.com', // Too long local part + 'user@' + 'a'.repeat(256) + '.com', // Too long domain + + // Disallowed characters + 'user name@example.com', // Space in local part + 'user@example,com', // Comma in domain + 'invalid@exam@ple.com', // Multiple @ symbols + + // No TLD + 'user@localhost', + + // Invalid special character placements + '.user@example.com', // Leading dot + 'user.@example.com', // Trailing dot + 'user..name@example.com' // Consecutive dots + ]; + + // Test valid emails + it.each(validEmails)('should validate valid email: %s', (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + // Test invalid emails + it.each(invalidEmails)('should invalidate invalid email: %s', (email) => { + expect(isValidEmail(email)).toBe(false); + }); + + // Email normalization tests + describe('Email Normalization', () => { + it('should normalize emails consistently', () => { + const testCases = [ + { input: ' User@Example.com ', expected: 'user@example.com' }, + { input: 'USER@EXAMPLE.COM', expected: 'user@example.com' }, + { input: '', expected: '' }, + { input: ' ', expected: '' } + ]; + + testCases.forEach(({ input, expected }) => { + expect(normalizeEmail(input)).toBe(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/user-model.test.ts b/__tests__/user-model.test.ts new file mode 100644 index 0000000..f28e140 --- /dev/null +++ b/__tests__/user-model.test.ts @@ -0,0 +1,56 @@ +import mongoose from 'mongoose'; +import User from '../models/User'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +describe('User Model Email Uniqueness', () => { + beforeAll(async () => { + // Connect to a test database + await mongoose.connect(process.env.MONGODB_TEST_URI || 'mongodb://localhost:27017/test'); + }); + + afterAll(async () => { + // Disconnect from the test database + await mongoose.connection.close(); + }); + + it('should prevent duplicate emails (case-insensitive)', async () => { + // Clear existing users + await User.deleteMany({}); + + // Create a user with a specific email + const originalUser = new User({ + email: 'test@example.com', + // Add other required fields if any + }); + await originalUser.save(); + + // Try to create another user with the same email (different case) + const duplicateUser = new User({ + email: 'TEST@EXAMPLE.COM', + // Add other required fields if any + }); + + // Expect an error due to duplicate email + await expect(duplicateUser.save()).rejects.toThrow(); + }); + + it('should check email uniqueness correctly', async () => { + // Clear existing users + await User.deleteMany({}); + + // Create a user + const originalUser = new User({ + email: 'unique@example.com', + // Add other required fields if any + }); + await originalUser.save(); + + // Check existing email + const isExistingEmailTaken = await User.isEmailTaken('unique@example.com'); + expect(isExistingEmailTaken).toBe(true); + + // Check non-existing email + const isNewEmailTaken = await User.isEmailTaken('new@example.com'); + expect(isNewEmailTaken).toBe(false); + }); +}); \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index ea6c54a..eeeba9b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,91 +1,58 @@ -import { programmingLanguages } from "@/constants"; import { type ClassValue, clsx } from "clsx"; -import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function truncateString(str: string, num: number) { - if (str.length <= num) { - return str; - } - return str.slice(0, num) + "..."; +/** + * Validate email format using a comprehensive regex + * @param email - Email address to validate + * @returns boolean indicating whether the email is valid + */ +export function isValidEmail(email: string): boolean { + // Comprehensive email validation regex + // Supports: + // - Standard email formats + // - Quoted local parts + // - Multiple dots in local part + // - Subdomains + // - IP address domains + 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,}))$/; + + // Validate input + if (!email || typeof email !== 'string') return false; + + // Trim and convert to lowercase for consistent validation + const normalizedEmail = email.trim().toLowerCase(); + + // Length constraints + const MAX_EMAIL_LENGTH = 254; + const MAX_LOCAL_PART_LENGTH = 64; + const MAX_DOMAIN_LENGTH = 255; + + // Check overall email length + if (normalizedEmail.length > MAX_EMAIL_LENGTH) return false; + + // Validate using comprehensive regex + if (!emailRegex.test(normalizedEmail)) return false; + + // Split email into local part and domain + const [localPart, domain] = normalizedEmail.split('@'); + + // Check lengths of local part and domain + if (localPart.length > MAX_LOCAL_PART_LENGTH) return false; + if (domain.length > MAX_DOMAIN_LENGTH) return false; + + return true; } -/* {Calculate Days left for job posting} */ -export function calculateDaysLeft(expTime: number): number { - if (!expTime) { - return 0; - } - const expDate = new Date(expTime * 1000); // Convert to milliseconds - const currentDate = new Date(); - - const timeDiff = expDate.getTime() - currentDate.getTime(); - const daysLeft = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); - - return daysLeft; -} - -export function findKeywords(jobDescription: string, maxKeywords: number = 7) { - const descriptionLowerCase = jobDescription.toLowerCase(); - const uniqueKeywords = new Set(); - - for (const programmingLanguage of programmingLanguages) { - if (uniqueKeywords.size >= maxKeywords) { - break; // Stop the loop if we've reached the maximum number of keywords - } - // Only use the lowercase version for comparison - if (descriptionLowerCase.includes(programmingLanguage.toLowerCase())) { - uniqueKeywords.add(programmingLanguage); // Add the original case version to the Set - } - } - - return Array.from(uniqueKeywords); -} - -/* {Calculate Days since job posting} */ -export function calculatePostDate(expTime: number): number { - if (!expTime) { - return 0; - } - const postDate = new Date(expTime * 1000); // Convert to milliseconds - const currentDate = new Date(); - - const timeDiff = currentDate.getTime() - postDate.getTime(); - const daysPosted = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); - - return daysPosted; -} - -export const addOrReplaceJobSearchParams = ( - paramsArray: Record[], - router: AppRouterInstance, - url: string = "/job-search", -) => { - if (!router) return; - if (!window) return; - - const queryParams = new URLSearchParams(window.location.search); - - paramsArray.forEach((param) => { - const key = Object.keys(param)[0]; - const value = param[key]; - if (key && value !== undefined) { - // Ensure the key is not empty and the value is not undefined - queryParams.set(key, value); - } - }); - - router.push(`${url}?${queryParams.toString()}`, { scroll: false }); -}; - -export function formatLocation( - city?: string | null, - state?: string | null, - country?: string | null, -): string | null { - const locationParts = [city, state, country].filter(Boolean); - return locationParts.length > 0 ? locationParts.join(", ") : null; -} +/** + * Normalize email for consistent comparison + * @param email - Email address to normalize + * @returns normalized email address + */ +export function normalizeEmail(email: string): string { + if (!email) return ''; + return email.trim().toLowerCase(); +} \ No newline at end of file diff --git a/models/User.ts b/models/User.ts new file mode 100644 index 0000000..93535a6 --- /dev/null +++ b/models/User.ts @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; +import { isValidEmail, normalizeEmail } from '../lib/utils'; + +// Define the User schema with unique email constraint +const UserSchema = new mongoose.Schema({ + email: { + type: String, + required: [true, 'Email is required'], + unique: true, // Database-level unique constraint + lowercase: true, // Always store in lowercase + trim: true, // Remove whitespace + index: true, // Create an index for faster querying + validate: { + validator: function(value: string) { + return isValidEmail(value); + }, + message: 'Invalid email format' + } + }, + // Other user fields can be added here +}, { + // Add a unique compound index to ensure case-insensitive uniqueness + indexes: [{ + email: 1 + }] +}); + +// Pre-save middleware to normalize email +UserSchema.pre('save', function(next) { + // Normalize email before saving + if (this.email) { + this.email = normalizeEmail(this.email); + } + next(); +}); + +// Custom method to check email uniqueness +UserSchema.statics.isEmailTaken = async function(email: string): Promise { + const normalizedEmail = normalizeEmail(email); + const user = await this.findOne({ email: normalizedEmail }); + return !!user; +}; + +// Create the User model (or use existing model if already created) +const User = mongoose.models.User || mongoose.model('User', UserSchema); + +export default User; \ No newline at end of file