Skip to content
Open
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
88 changes: 88 additions & 0 deletions __tests__/email-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
56 changes: 56 additions & 0 deletions __tests__/user-model.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
131 changes: 49 additions & 82 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<string, string>[],
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();
}
47 changes: 47 additions & 0 deletions models/User.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;