From ba6de6de4d9cef488f01097d6bc1367d7979a35a Mon Sep 17 00:00:00 2001 From: AdithaBuwaneka Date: Tue, 22 Jul 2025 19:27:33 +0530 Subject: [PATCH] Add comprehensive tests for User schema and JWT authentication utilities - Implement tests for User model including password hashing, comparison, and schema validation. - Mock bcrypt and mongoose for isolated testing of password-related functionalities. - Validate password handling in various scenarios including error cases. - Add tests for JWT authentication utilities to ensure proper token validation and extraction. - Mock jsonwebtoken to simulate different JWT scenarios and edge cases. - Ensure environment variable checks and error handling are covered in tests. --- __tests__/api/auth/forgot-password.test.ts | 409 +++++++++++++ __tests__/api/auth/google-oauth.test.ts | 670 +++++++++++++++++++++ __tests__/api/auth/login.test.ts | 368 +++++++++++ __tests__/api/auth/register.test.ts | 419 +++++++++++++ __tests__/api/auth/verify-otp.test.ts | 519 ++++++++++++++++ __tests__/models/userSchema.test.ts | 483 +++++++++++++++ __tests__/utils/jwtAuth.test.ts | 494 +++++++++++++++ jest.setup.js | 103 ++++ 8 files changed, 3465 insertions(+) create mode 100644 __tests__/api/auth/forgot-password.test.ts create mode 100644 __tests__/api/auth/google-oauth.test.ts create mode 100644 __tests__/api/auth/login.test.ts create mode 100644 __tests__/api/auth/register.test.ts create mode 100644 __tests__/api/auth/verify-otp.test.ts create mode 100644 __tests__/models/userSchema.test.ts create mode 100644 __tests__/utils/jwtAuth.test.ts diff --git a/__tests__/api/auth/forgot-password.test.ts b/__tests__/api/auth/forgot-password.test.ts new file mode 100644 index 00000000..4de4d7f1 --- /dev/null +++ b/__tests__/api/auth/forgot-password.test.ts @@ -0,0 +1,409 @@ +/** + * Authentication API Tests - Forgot Password Route + * Tests for /api/forgot-password endpoint including OTP generation and email sending + */ + +import { POST } from '@/app/api/forgot-password/route'; +import dbConnect from '@/lib/db'; +import User from '@/lib/models/userSchema'; +import OtpVerification from '@/lib/models/otpVerificationSchema'; +import sgMail from '@sendgrid/mail'; + +// Mock dependencies +jest.mock('@/lib/db'); +jest.mock('@/lib/models/userSchema'); +jest.mock('@/lib/models/otpVerificationSchema'); +jest.mock('@sendgrid/mail'); + +const mockDbConnect = dbConnect as jest.MockedFunction; +const mockUser = User as jest.Mocked; +const mockOtpVerification = OtpVerification as jest.Mocked; +const mockSgMail = sgMail as jest.Mocked; + +// Mock environment variables +const originalEnv = process.env; + +describe('/api/forgot-password', () => { + beforeAll(() => { + process.env.SENDGRID_API_KEY = 'test-sendgrid-api-key'; + process.env.SENDGRID_FROM_EMAIL = 'noreply@skillswaphub.com'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDbConnect.mockResolvedValue(undefined); + + // Mock static methods + mockOtpVerification.deleteMany = jest.fn().mockResolvedValue({ deletedCount: 1 }); + mockOtpVerification.create = jest.fn().mockResolvedValue({ + _id: 'otp123', + userId: 'user123', + otp: '123456', + expiresAt: new Date(), + used: false + }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Environment Variables', () => { + test('should require SENDGRID_API_KEY environment variable', () => { + delete process.env.SENDGRID_API_KEY; + + expect(() => { + jest.resetModules(); + require('@/app/api/forgot-password/route'); + }).toThrow('SENDGRID_API_KEY is not defined'); + + // Restore for other tests + process.env.SENDGRID_API_KEY = 'test-sendgrid-api-key'; + }); + }); + + describe('Input Validation', () => { + test('should return 400 when email is missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email is required'); + }); + + test('should return 400 when email is null', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: null }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email is required'); + }); + + test('should return 400 when email is empty string', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: '' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email is required'); + }); + }); + + describe('Security - Email Enumeration Prevention', () => { + test('should return success even when user does not exist (prevents email enumeration)', async () => { + mockUser.findOne.mockResolvedValue(null); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'nonexistent@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('If your email is registered, you will receive a reset code shortly'); + + expect(mockUser.findOne).toHaveBeenCalledWith({ email: 'nonexistent@example.com' }); + + // Should not attempt to send email or create OTP for non-existent user + expect(mockOtpVerification.deleteMany).not.toHaveBeenCalled(); + expect(mockOtpVerification.create).not.toHaveBeenCalled(); + expect(mockSgMail.send).not.toHaveBeenCalled(); + }); + }); + + describe('OTP Generation and Email Sending', () => { + const mockUserData = { + _id: 'user123', + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe' + }; + + beforeEach(() => { + mockSgMail.send.mockResolvedValue([{}, {}] as any); + }); + + test('should successfully generate OTP and send email for existing user', async () => { + mockUser.findOne.mockResolvedValue(mockUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('If your email is registered, you will receive a reset code shortly'); + + // Should find the user + expect(mockUser.findOne).toHaveBeenCalledWith({ email: 'john@example.com' }); + + // Should delete existing OTP records + expect(mockOtpVerification.deleteMany).toHaveBeenCalledWith({ userId: 'user123' }); + + // Should create new OTP record + expect(mockOtpVerification.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user123', + email: 'john@example.com', + otp: expect.stringMatching(/^\d{6}$/), // 6-digit OTP + expiresAt: expect.any(Date), + used: false + }) + ); + + // Should send email + expect(mockSgMail.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'john@example.com', + from: 'noreply@skillswaphub.com', + subject: 'SkillSwap Hub Password Reset', + text: expect.stringContaining('Your password reset code is:'), + html: expect.stringContaining('Password Reset') + }) + ); + }); + + test('should generate 6-digit OTP', async () => { + mockUser.findOne.mockResolvedValue(mockUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + await POST(request); + + const createCall = mockOtpVerification.create.mock.calls[0][0]; + expect(createCall.otp).toMatch(/^\d{6}$/); + expect(parseInt(createCall.otp)).toBeGreaterThanOrEqual(100000); + expect(parseInt(createCall.otp)).toBeLessThanOrEqual(999999); + }); + + test('should set OTP expiry to 5 minutes from now', async () => { + const beforeRequest = Date.now(); + mockUser.findOne.mockResolvedValue(mockUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + await POST(request); + + const afterRequest = Date.now(); + const createCall = mockOtpVerification.create.mock.calls[0][0]; + const expiryTime = new Date(createCall.expiresAt).getTime(); + + // Should be approximately 5 minutes (300,000 ms) from request time + const expectedMinExpiry = beforeRequest + 5 * 60 * 1000; + const expectedMaxExpiry = afterRequest + 5 * 60 * 1000; + + expect(expiryTime).toBeGreaterThanOrEqual(expectedMinExpiry); + expect(expiryTime).toBeLessThanOrEqual(expectedMaxExpiry); + }); + + test('should include OTP in email content', async () => { + mockUser.findOne.mockResolvedValue(mockUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + await POST(request); + + const createCall = mockOtpVerification.create.mock.calls[0][0]; + const generatedOTP = createCall.otp; + + const emailCall = mockSgMail.send.mock.calls[0][0]; + const emailData = Array.isArray(emailCall) ? emailCall[0] : emailCall; + + expect(emailData.text).toContain(generatedOTP); + expect(emailData.html).toContain(generatedOTP); + }); + + test('should clean up existing OTP records before creating new one', async () => { + mockUser.findOne.mockResolvedValue(mockUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + await POST(request); + + // Should delete existing records first + expect(mockOtpVerification.deleteMany).toHaveBeenCalledWith({ userId: 'user123' }); + + // Then create new record + expect(mockOtpVerification.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user123', + email: 'john@example.com', + otp: expect.stringMatching(/^\d{6}$/), + expiresAt: expect.any(Date), + used: false + }) + ); + }); + }); + + describe('Email Configuration', () => { + const mockUserData = { + _id: 'user123', + email: 'john@example.com' + }; + + test('should use default from email when SENDGRID_FROM_EMAIL is not set', async () => { + delete process.env.SENDGRID_FROM_EMAIL; + mockUser.findOne.mockResolvedValue(mockUserData); + mockSgMail.send.mockResolvedValue([{}, {}] as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + await POST(request); + + const emailCall = mockSgMail.send.mock.calls[0][0]; + const fromValue = Array.isArray(emailCall) ? emailCall[0].from : emailCall.from; + expect(fromValue).toBe('noreply@skillswaphub.com'); + + // Restore for other tests + process.env.SENDGRID_FROM_EMAIL = 'noreply@skillswaphub.com'; + }); + + test('should use configured from email', async () => { + process.env.SENDGRID_FROM_EMAIL = 'custom@example.com'; + mockUser.findOne.mockResolvedValue(mockUserData); + mockSgMail.send.mockResolvedValue([{}, {}] as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + await POST(request); + + const emailCall = mockSgMail.send.mock.calls[0][0]; + const fromValue = Array.isArray(emailCall) ? emailCall[0].from : emailCall.from; + expect(fromValue).toBe('custom@example.com'); + }); + }); + + describe('Error Handling', () => { + test('should handle database connection errors', async () => { + mockDbConnect.mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while processing your request'); + }); + + test('should handle email sending errors', async () => { + const mockUserData = { + _id: 'user123', + email: 'john@example.com' + }; + + mockUser.findOne.mockResolvedValue(mockUserData); + mockSgMail.send.mockRejectedValue(new Error('Email service unavailable')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while processing your request'); + }); + + test('should handle OTP creation errors', async () => { + const mockUserData = { + _id: 'user123', + email: 'john@example.com' + }; + + mockUser.findOne.mockResolvedValue(mockUserData); + mockOtpVerification.create.mockRejectedValue(new Error('Database write error')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while processing your request'); + }); + + test('should handle malformed JSON', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while processing your request'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/auth/google-oauth.test.ts b/__tests__/api/auth/google-oauth.test.ts new file mode 100644 index 00000000..cfb2cf16 --- /dev/null +++ b/__tests__/api/auth/google-oauth.test.ts @@ -0,0 +1,670 @@ +/** + * Authentication API Tests - Google OAuth Route + * Tests for /api/auth/google endpoint including OAuth2 flow and user linking + */ + +import { POST } from '@/app/api/auth/google/route'; +import { NextRequest } from 'next/server'; +import dbConnect from '@/lib/db'; +import User from '@/lib/models/userSchema'; +import SuspendedUser from '@/lib/models/suspendedUserSchema'; +import jwt from 'jsonwebtoken'; +import { OAuth2Client } from 'google-auth-library'; + +// Mock dependencies +jest.mock('@/lib/db'); +jest.mock('@/lib/models/userSchema'); +jest.mock('@/lib/models/suspendedUserSchema'); +jest.mock('jsonwebtoken'); +jest.mock('google-auth-library'); + +const mockDbConnect = dbConnect as jest.MockedFunction; +const mockUser = User as jest.Mocked; +const mockSuspendedUser = SuspendedUser as jest.Mocked; +let jwtSignSpy: jest.SpyInstance; +const mockJwt = jwt as jest.Mocked; +const mockOAuth2Client = OAuth2Client as jest.MockedClass; + +// Mock environment variables +const originalEnv = process.env; + +describe('/api/auth/google', () => { + const mockTicket = { + getPayload: jest.fn() + }; + + const mockClientInstance = { + verifyIdToken: jest.fn() + }; + + beforeAll(() => { + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + process.env.GOOGLE_CLIENT_ID = 'test-google-client-id'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDbConnect.mockResolvedValue(undefined); + mockOAuth2Client.mockImplementation(() => mockClientInstance as any); + if (jwtSignSpy) jwtSignSpy.mockRestore(); + }); + // Helper to create a NextRequest from a standard Request + function createNextRequest(input: Request): NextRequest { + + return input as unknown as NextRequest; + } + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Environment Variables', () => { + test('should require JWT_SECRET environment variable', () => { + delete process.env.JWT_SECRET; + + expect(() => { + jest.resetModules(); + require('@/app/api/auth/google/route'); + }).toThrow('JWT_SECRET is not defined'); + + // Restore for other tests + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + test('should require GOOGLE_CLIENT_ID environment variable', () => { + delete process.env.GOOGLE_CLIENT_ID; + + expect(() => { + jest.resetModules(); + require('@/app/api/auth/google/route'); + }).toThrow('GOOGLE_CLIENT_ID is not defined'); + + // Restore for other tests + process.env.GOOGLE_CLIENT_ID = 'test-google-client-id'; + }); + }); + + describe('Input Validation', () => { + test('should return 400 when credential is missing', async () => { + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Google credential is required'); + }); + + test('should return 400 when credential is null', async () => { + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: null }) + }); + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Google credential is required'); + }); + + test('should return 400 when credential is empty string', async () => { + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: '' }) + }); + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Google credential is required'); + }); + }); + + describe('Google Token Validation', () => { + test('should return 401 when Google token is invalid', async () => { + mockTicket.getPayload.mockReturnValue(null); + + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'invalid.token.here' }) + }); + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid Google token'); + + expect(mockClientInstance.verifyIdToken).toHaveBeenCalledWith({ + idToken: 'invalid.token.here', + audience: 'test-google-client-id' + }); + }); + + test('should return 401 when Google token verification fails', async () => { + mockClientInstance.verifyIdToken.mockRejectedValue(new Error('Token verification failed')); + + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'invalid.token.here' }) + }); + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('Google authentication failed'); + }); + + test('should return 400 when required user information is missing from Google', async () => { + // Missing lastName + const incompletePayload = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + // family_name: 'Doe', // Missing + picture: 'https://example.com/photo.jpg' + }; + + mockTicket.getPayload.mockReturnValue(incompletePayload); + + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Required user information not available from Google'); + }); + }); + + describe('User Suspension Checks', () => { + const validGooglePayload = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + family_name: 'Doe', + picture: 'https://example.com/photo.jpg' + }; + + beforeEach(() => { + mockTicket.getPayload.mockReturnValue(validGooglePayload); + }); + + test('should return 403 when user is suspended', async () => { + const suspendedUserData = { + email: 'john@example.com', + suspensionReason: 'Terms of Service violation', + suspendedAt: new Date(), + suspensionNotes: 'Inappropriate behavior in forum' + }; + + mockSuspendedUser.findOne.mockResolvedValue(suspendedUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.success).toBe(false); + expect(data.suspended).toBe(true); + expect(data.message).toContain('suspended'); + expect(data.suspensionDetails).toEqual({ + reason: 'Terms of Service violation', + suspendedAt: suspendedUserData.suspendedAt.toISOString(), + notes: 'Inappropriate behavior in forum' + }); + + expect(mockSuspendedUser.findOne).toHaveBeenCalledWith({ email: 'john@example.com' }); + }); + }); + + describe('User Authentication Flow', () => { + const validGooglePayload = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + family_name: 'Doe', + picture: 'https://example.com/photo.jpg' + }; + + beforeEach(() => { + mockTicket.getPayload.mockReturnValue(validGooglePayload); + mockSuspendedUser.findOne.mockResolvedValue(null); + }); + + describe('Existing Google User', () => { + test('should successfully login existing Google user', async () => { + const existingGoogleUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + googleId: 'google123', + isGoogleUser: true, + avatar: 'https://example.com/photo.jpg', + profileCompleted: true, + phone: '+1234567890', + title: 'Software Developer' + }; + + const mockToken = 'jwt.token.here'; + + mockUser.findOne + .mockResolvedValueOnce(existingGoogleUser) // First call by googleId + .mockResolvedValueOnce(null); // Second call by email (not needed) + + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue(mockToken as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('Google authentication successful'); + expect(data.token).toBe(mockToken); + expect(data.needsProfileCompletion).toBe(false); + expect(data.user).toEqual({ + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + title: 'Software Developer', + avatar: 'https://example.com/photo.jpg', + isGoogleUser: true, + profileCompleted: true + }); + + expect(mockUser.findOne).toHaveBeenCalledWith({ googleId: 'google123' }); + }); + }); + + describe('Linking Google to Existing Email User', () => { + test('should link Google account to existing email/password user', async () => { + const existingEmailUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + googleId: null, + isGoogleUser: false, + avatar: null, + profileCompleted: false, + phone: '+1234567890', + title: 'Software Developer', + save: jest.fn().mockResolvedValue(true) + }; + + const mockToken = 'jwt.token.here'; + + mockUser.findOne + .mockResolvedValueOnce(null) // First call by googleId + .mockResolvedValueOnce(existingEmailUser); // Second call by email + + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue(mockToken as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.needsProfileCompletion).toBe(false); + + // Should update existing user with Google info + expect(existingEmailUser.googleId).toBe('google123'); + expect(existingEmailUser.isGoogleUser).toBe(true); + expect(existingEmailUser.avatar).toBe('https://example.com/photo.jpg'); + expect(existingEmailUser.profileCompleted).toBe(true); + expect(existingEmailUser.save).toHaveBeenCalled(); + }); + + test('should preserve existing avatar when linking Google account', async () => { + const existingEmailUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + googleId: null, + isGoogleUser: false, + avatar: 'https://existing.com/avatar.jpg', // Existing avatar + profileCompleted: false, + save: jest.fn().mockResolvedValue(true) + }; + + mockUser.findOne + .mockResolvedValueOnce(null) // First call by googleId + .mockResolvedValueOnce(existingEmailUser); // Second call by email + + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue('token' as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + await POST(createNextRequest(request)); + + // Should keep existing avatar, not overwrite with Google's + expect(existingEmailUser.avatar).toBe('https://existing.com/avatar.jpg'); + }); + }); + + describe('New Google User Creation', () => { + test('should create new user for new Google account', async () => { + const newGoogleUser = { + _id: 'newuser123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + googleId: 'google123', + isGoogleUser: true, + avatar: 'https://example.com/photo.jpg', + profileCompleted: true, + phone: '', + title: '', + save: jest.fn().mockResolvedValue(true) + }; + + const mockToken = 'jwt.token.here'; + + mockUser.findOne + .mockResolvedValueOnce(null) // First call by googleId + .mockResolvedValueOnce(null); // Second call by email + + // @ts-expect-error: allow constructor mocking for test + mockUser.mockImplementation(() => newGoogleUser as any); + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue(mockToken as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.needsProfileCompletion).toBe(false); + + expect(mockUser).toHaveBeenCalledWith({ + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + googleId: 'google123', + isGoogleUser: true, + avatar: 'https://example.com/photo.jpg', + profileCompleted: true, + phone: '', + title: '' + }); + + expect(newGoogleUser.save).toHaveBeenCalled(); + }); + + test('should handle Google user without profile picture', async () => { + const googlePayloadWithoutPicture = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + family_name: 'Doe', + picture: undefined + }; + + mockTicket.getPayload.mockReturnValue(googlePayloadWithoutPicture); + + const newGoogleUser = { + _id: 'newuser123', + save: jest.fn().mockResolvedValue(true) + }; + + mockUser.findOne.mockResolvedValue(null); + // @ts-expect-error: allow constructor mocking for test + mockUser.mockImplementation(() => newGoogleUser as any); + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue('token' as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + await POST(createNextRequest(request)); + + expect(mockUser).toHaveBeenCalledWith({ + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + googleId: 'google123', + isGoogleUser: true, + avatar: undefined, + profileCompleted: true, + phone: '', + title: '' + }); + }); + }); + }); + + describe('JWT Token Generation', () => { + const validGooglePayload = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + family_name: 'Doe', + picture: 'https://example.com/photo.jpg' + }; + + beforeEach(() => { + mockTicket.getPayload.mockReturnValue(validGooglePayload); + mockSuspendedUser.findOne.mockResolvedValue(null); + }); + + test('should generate JWT token with correct payload and 24h expiry', async () => { + const mockGoogleUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com' + }; + + const mockToken = 'jwt.token.here'; + + mockUser.findOne.mockResolvedValue(mockGoogleUser); + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue(mockToken as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + await POST(createNextRequest(request)); + + expect(mockJwt.sign).toHaveBeenCalledWith( + { + userId: 'user123', + email: 'john@example.com', + name: 'John Doe' + }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '24h' } + ); + }); + }); + + describe('Profile Completion', () => { + const validGooglePayload = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + family_name: 'Doe', + picture: 'https://example.com/photo.jpg' + }; + + beforeEach(() => { + mockTicket.getPayload.mockReturnValue(validGooglePayload); + mockSuspendedUser.findOne.mockResolvedValue(null); + }); + + test('should mark Google users as having completed profile', async () => { + const incompleteMockUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + profileCompleted: false, + save: jest.fn().mockResolvedValue(true) + }; + + mockUser.findOne.mockResolvedValue(incompleteMockUser); + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue('token' as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + await POST(createNextRequest(request)); + + expect(incompleteMockUser.profileCompleted).toBe(true); + expect(incompleteMockUser.save).toHaveBeenCalled(); + }); + + test('should always return needsProfileCompletion as false for Google users', async () => { + const mockGoogleUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '', + title: '', + profileCompleted: true + }; + + mockUser.findOne.mockResolvedValue(mockGoogleUser); + jwtSignSpy = jest.spyOn(jwt, 'sign').mockReturnValue('token' as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(data.needsProfileCompletion).toBe(false); + }); + }); + + describe('Error Handling', () => { + test('should handle database connection errors', async () => { + mockDbConnect.mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('Google authentication failed'); + }); + + test('should handle user save errors', async () => { + const validGooglePayload = { + sub: 'google123', + email: 'john@example.com', + given_name: 'John', + family_name: 'Doe', + picture: 'https://example.com/photo.jpg' + }; + + mockTicket.getPayload.mockReturnValue(validGooglePayload); + mockSuspendedUser.findOne.mockResolvedValue(null); + + const newGoogleUser = { + _id: 'newuser123', + save: jest.fn().mockRejectedValue(new Error('Database save failed')) + }; + + mockUser.findOne.mockResolvedValue(null); + // @ts-expect-error: allow constructor mocking for test + mockUser.mockImplementation(() => newGoogleUser as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: 'valid.token.here' }) + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('Google authentication failed'); + }); + + test('should handle malformed JSON', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json' + }); + + const response = await POST(createNextRequest(request)); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('Google authentication failed'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/auth/login.test.ts b/__tests__/api/auth/login.test.ts new file mode 100644 index 00000000..a2b8ef77 --- /dev/null +++ b/__tests__/api/auth/login.test.ts @@ -0,0 +1,368 @@ +/** + * Authentication API Tests - Login Route + * Tests for /api/login endpoint including JWT token generation and validation + */ + +import { POST } from '@/app/api/login/route'; +import dbConnect from '@/lib/db'; +import User from '@/lib/models/userSchema'; +import SuspendedUser from '@/lib/models/suspendedUserSchema'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; + +// Polyfill global Request for Node.js/Jest if not present +if (typeof global.Request === 'undefined') { + + global.Request = require('node-fetch').Request; +} + +// Mock dependencies +jest.mock('@/lib/db'); +jest.mock('@/lib/models/userSchema'); +jest.mock('@/lib/models/suspendedUserSchema'); +jest.mock('jsonwebtoken'); +jest.mock('bcryptjs'); + +const mockDbConnect = dbConnect as jest.MockedFunction; +const mockUser = User as jest.Mocked; +const mockSuspendedUser = SuspendedUser as jest.Mocked; +const mockJwt = jwt as jest.Mocked; +// Helper for type-safe mocking of jwt.sign +const mockJwtSign = jwt.sign as unknown as jest.Mock; + + +// Mock environment variables +const originalEnv = { ...process.env }; + +describe('/api/login', () => { + beforeAll(() => { + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDbConnect.mockResolvedValue(undefined); + // Reset env for each test to avoid pollution + process.env = { ...originalEnv }; + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Input Validation', () => { + test('should return 400 when email is missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'password123' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and password are required'); + }); + + test('should return 400 when password is missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and password are required'); + }); + + test('should return 400 when both email and password are missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and password are required'); + }); + }); + + describe('User Suspension Checks', () => { + test('should return 403 when user is suspended', async () => { + const suspendedUserData = { + email: 'suspended@example.com', + suspensionReason: 'Terms of Service violation', + suspendedAt: new Date(), + suspensionNotes: 'Inappropriate behavior in forum' + }; + + mockSuspendedUser.findOne.mockResolvedValue(suspendedUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'suspended@example.com', + password: 'password123' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.success).toBe(false); + expect(data.suspended).toBe(true); + expect(data.message).toContain('suspended'); + expect(data.suspensionDetails).toEqual({ + reason: 'Terms of Service violation', + suspendedAt: suspendedUserData.suspendedAt.toISOString(), + notes: 'Inappropriate behavior in forum' + }); + + expect(mockSuspendedUser.findOne).toHaveBeenCalledWith({ email: 'suspended@example.com' }); + }); + }); + + describe('Authentication Flow', () => { + test('should return 401 when user does not exist', async () => { + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(null); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'nonexistent@example.com', + password: 'password123' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid email or password'); + + expect(mockUser.findOne).toHaveBeenCalledWith({ email: 'nonexistent@example.com' }); + }); + + test('should return 401 when password is incorrect', async () => { + const mockUserData = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + comparePassword: jest.fn().mockResolvedValue(false) + }; + + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(mockUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + password: 'wrongpassword' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid email or password'); + + expect(mockUserData.comparePassword).toHaveBeenCalledWith('wrongpassword'); + }); + + test('should successfully login with correct credentials (no remember me)', async () => { + const mockUserData = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + comparePassword: jest.fn().mockResolvedValue(true) + }; + + const mockToken = 'jwt.token.here'; + + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(mockUserData); + mockJwtSign.mockReturnValue(mockToken); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + password: 'correctpassword', + rememberMe: false + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('Login successful'); + expect(data.token).toBe(mockToken); + expect(data.user).toEqual(expect.objectContaining({ + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com' + })); + + expect(mockUserData.comparePassword).toHaveBeenCalledWith('correctpassword'); + expect(mockJwt.sign).toHaveBeenCalledWith( + { + userId: 'user123', + email: 'john@example.com', + name: 'John Doe' + }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '24h' } + ); + }); + + test('should successfully login with remember me option (extended expiry)', async () => { + const mockUserData = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + comparePassword: jest.fn().mockResolvedValue(true) + }; + + const mockToken = 'jwt.token.here'; + + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(mockUserData); + mockJwtSign.mockReturnValue(mockToken); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + password: 'correctpassword', + rememberMe: true + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.token).toBe(mockToken); + + expect(mockJwt.sign).toHaveBeenCalledWith( + { + userId: 'user123', + email: 'john@example.com', + name: 'John Doe' + }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '30d' } + ); + }); + }); + + describe('Database Connection', () => { + test('should handle database connection errors', async () => { + mockDbConnect.mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'password123' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred during login'); + }); + }); + + describe('Security Features', () => { + test('should not expose sensitive information in error messages', async () => { + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(null); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'password123' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + // Should not indicate whether email exists or not + expect(data.message).toBe('Invalid email or password'); + expect(data.message).not.toContain('User not found'); + expect(data.message).not.toContain('email does not exist'); + }); + + test('should require JWT_SECRET environment variable', () => { + delete process.env.JWT_SECRET; + + expect(() => { + jest.resetModules(); + require('@/app/api/login/route'); + }).toThrow('JWT_SECRET is not defined'); + + // Restore for other tests + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + }); + + describe('Error Handling', () => { + test('should handle unexpected errors gracefully', async () => { + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockRejectedValue(new Error('Unexpected database error')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'password123' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred during login'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/auth/register.test.ts b/__tests__/api/auth/register.test.ts new file mode 100644 index 00000000..ee2a2b4c --- /dev/null +++ b/__tests__/api/auth/register.test.ts @@ -0,0 +1,419 @@ +/** + * Authentication API Tests - Register Route + * Tests for /api/register endpoint including user creation and validation + */ + +import { POST } from '@/app/api/register/route'; +import dbConnect from '@/lib/db'; +import User from '@/lib/models/userSchema'; +import SuspendedUser from '@/lib/models/suspendedUserSchema'; +import jwt from 'jsonwebtoken'; + +// Mock dependencies +jest.mock('@/lib/db'); +jest.mock('@/lib/models/userSchema'); +jest.mock('@/lib/models/suspendedUserSchema'); +jest.mock('jsonwebtoken'); + +const mockDbConnect = dbConnect as jest.MockedFunction; +const mockUser = User as jest.Mocked; +const mockSuspendedUser = SuspendedUser as jest.Mocked; +const mockJwt = jwt as jest.Mocked; +// Helper for type-safe mocking of jwt.sign +const mockJwtSign = jwt.sign as unknown as jest.Mock; + +// Polyfill global Request for Node.js/Jest if not present +if (typeof global.Request === 'undefined') { + global.Request = require('node-fetch').Request; +} + +// Mock environment variables +const originalEnv = { ...process.env }; + +describe('/api/register', () => { + const validUserData = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + title: 'Software Developer', + password: 'securePassword123!' + }; + + beforeAll(() => { + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDbConnect.mockResolvedValue(undefined); + // Reset env for each test to avoid pollution + process.env = { ...originalEnv }; + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Input Validation', () => { + const requiredFields = ['firstName', 'lastName', 'email', 'phone', 'title', 'password']; + + requiredFields.forEach(field => { + test(`should return 400 when ${field} is missing`, async () => { + const incompleteData = { ...validUserData }; + delete incompleteData[field as keyof typeof incompleteData]; + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(incompleteData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe(`${field.charAt(0).toUpperCase() + field.slice(1)} is required`); + }); + }); + + test('should return 400 when all fields are missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('FirstName is required'); + }); + }); + + describe('Suspension Checks', () => { + test('should return 403 when user email is suspended', async () => { + const suspendedUserData = { + email: 'suspended@example.com', + phone: '+1234567890', + suspensionReason: 'Terms violation', + suspendedAt: new Date(), + suspensionNotes: 'Inappropriate behavior' + }; + + mockSuspendedUser.findOne.mockResolvedValue(suspendedUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...validUserData, + email: 'suspended@example.com' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.success).toBe(false); + expect(data.suspended).toBe(true); + expect(data.message).toContain('suspended'); + expect(data.suspensionDetails).toEqual({ + reason: 'Terms violation', + suspendedAt: suspendedUserData.suspendedAt.toISOString(), + notes: 'Inappropriate behavior' + }); + + expect(mockSuspendedUser.findOne).toHaveBeenCalledWith({ + $or: [{ email: 'suspended@example.com' }, { phone: '+1234567890' }] + }); + }); + + test('should return 403 when user phone is suspended', async () => { + const suspendedUserData = { + email: 'different@example.com', + phone: '+1234567890', + suspensionReason: 'Spam activities', + suspendedAt: new Date(), + suspensionNotes: 'Multiple spam reports' + }; + + mockSuspendedUser.findOne.mockResolvedValue(suspendedUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validUserData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.success).toBe(false); + expect(data.suspended).toBe(true); + expect(data.message).toContain('suspended'); + }); + }); + + describe('User Creation', () => { + beforeEach(() => { + mockSuspendedUser.findOne.mockResolvedValue(null); + }); + + test('should return 409 when user already exists', async () => { + const existingUser = { + _id: 'existing123', + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe' + }; + + mockUser.findOne.mockResolvedValue(existingUser); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validUserData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(409); + expect(data.success).toBe(false); + expect(data.message).toBe('Email already in use'); + + expect(mockUser.findOne).toHaveBeenCalledWith({ email: 'john@example.com' }); + }); + + test('should successfully create new user and return JWT token', async () => { + const mockNewUser = { + _id: 'newuser123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + title: 'Software Developer', + save: jest.fn().mockResolvedValue(true) + }; + + const mockToken = 'jwt.token.here'; + + mockUser.findOne.mockResolvedValue(null); + (mockUser as unknown as jest.Mock).mockImplementation(() => mockNewUser as any); + mockJwtSign.mockReturnValue(mockToken); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validUserData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('Registration successful'); + expect(data.token).toBe(mockToken); + expect(data.user).toEqual(expect.objectContaining({ + _id: 'newuser123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + title: 'Software Developer' + })); + + expect(mockUser).toHaveBeenCalledWith({ + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + title: 'Software Developer', + password: 'securePassword123!' + }); + + expect(mockNewUser.save).toHaveBeenCalled(); + + expect(mockJwt.sign).toHaveBeenCalledWith( + { + userId: 'newuser123', + email: 'john@example.com', + name: 'John Doe' + }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '24h' } + ); + }); + + test('should handle user save errors', async () => { + const mockNewUser = { + _id: 'newuser123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + save: jest.fn().mockRejectedValue(new Error('Database save error')) + }; + + mockUser.findOne.mockResolvedValue(null); + (mockUser as unknown as jest.Mock).mockImplementation(() => mockNewUser as any); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validUserData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred during registration'); + }); + }); + + describe('Database Connection', () => { + test('should handle database connection errors', async () => { + mockDbConnect.mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validUserData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred during registration'); + }); + }); + + describe('Security Features', () => { + test('should require JWT_SECRET environment variable', () => { + delete process.env.JWT_SECRET; + + expect(() => { + jest.resetModules(); + require('@/app/api/register/route'); + }).toThrow('JWT_SECRET is not defined'); + + // Restore for other tests + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + test('should prevent duplicate email registrations', async () => { + const existingUser = { _id: 'existing123', email: 'john@example.com' }; + + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(existingUser); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validUserData) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(409); + expect(data.success).toBe(false); + expect(data.message).toBe('Email already in use'); + }); + }); + + describe('Edge Cases', () => { + test('should handle malformed JSON', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred during registration'); + }); + + test('should handle suspension check with no notes', async () => { + const suspendedUserData = { + email: 'suspended@example.com', + phone: '+1234567890', + suspensionReason: 'Terms violation', + suspendedAt: new Date(), + suspensionNotes: null + }; + + mockSuspendedUser.findOne.mockResolvedValue(suspendedUserData); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...validUserData, + email: 'suspended@example.com' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.suspensionDetails.notes).toBe('No additional notes provided.'); + }); + }); + + describe('Data Integrity', () => { + test('should create user with correct field mapping', async () => { + const mockNewUser = { + _id: 'newuser123', + save: jest.fn().mockResolvedValue(true) + }; + + mockSuspendedUser.findOne.mockResolvedValue(null); + mockUser.findOne.mockResolvedValue(null); + (mockUser as unknown as jest.Mock).mockImplementation(() => mockNewUser as any); + mockJwtSign.mockReturnValue('token'); + + const customUserData = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane.smith@company.com', + phone: '+9876543210', + title: 'Product Manager', + password: 'superSecurePass456!' + }; + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(customUserData) + }); + + await POST(request); + + expect(mockUser).toHaveBeenCalledWith({ + firstName: 'Jane', + lastName: 'Smith', + email: 'jane.smith@company.com', + phone: '+9876543210', + title: 'Product Manager', + password: 'superSecurePass456!' + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/auth/verify-otp.test.ts b/__tests__/api/auth/verify-otp.test.ts new file mode 100644 index 00000000..a3df4c20 --- /dev/null +++ b/__tests__/api/auth/verify-otp.test.ts @@ -0,0 +1,519 @@ +/** + * Authentication API Tests - Verify OTP Route + * Tests for /api/verify-otp endpoint including OTP validation and reset token generation + */ + +import { POST } from '@/app/api/verify-otp/route'; +import dbConnect from '@/lib/db'; +import User from '@/lib/models/userSchema'; +import OtpVerification from '@/lib/models/otpVerificationSchema'; +import jwt from 'jsonwebtoken'; + +// Mock dependencies +jest.mock('@/lib/db'); +jest.mock('@/lib/models/userSchema'); +jest.mock('@/lib/models/otpVerificationSchema'); +jest.mock('jsonwebtoken'); + +const mockDbConnect = dbConnect as jest.MockedFunction; +const mockUser = User as jest.Mocked; +const mockOtpVerification = OtpVerification as jest.Mocked; +const mockJwt = jwt as jest.Mocked; +// Helper for type-safe mocking of jwt.sign +const mockJwtSign = jwt.sign as unknown as jest.Mock; + +// Polyfill global Request for Node.js/Jest if not present +if (typeof global.Request === 'undefined') { + global.Request = require('node-fetch').Request; +} + +// Mock environment variables +const originalEnv = { ...process.env }; + +describe('/api/verify-otp', () => { + beforeAll(() => { + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDbConnect.mockResolvedValue(undefined); + // Reset env for each test to avoid pollution + process.env = { ...originalEnv }; + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Input Validation', () => { + test('should return 400 when email is missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ otp: '123456' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and OTP are required'); + }); + + test('should return 400 when OTP is missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and OTP are required'); + }); + + test('should return 400 when both email and OTP are missing', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and OTP are required'); + }); + + test('should return 400 when email is empty string', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: '', otp: '123456' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and OTP are required'); + }); + + test('should return 400 when OTP is empty string', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'john@example.com', otp: '' }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toBe('Email and OTP are required'); + }); + }); + + describe('User Validation', () => { + test('should return 401 when user does not exist', async () => { + mockUser.findOne.mockResolvedValue(null); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'nonexistent@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid email or OTP'); + + expect(mockUser.findOne).toHaveBeenCalledWith({ email: 'nonexistent@example.com' }); + }); + }); + + describe('OTP Validation', () => { + const userObj = { + _id: 'user123', + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe' + }; + + beforeEach(() => { + mockUser.findOne.mockResolvedValue(userObj); + }); + + test('should return 401 when OTP record does not exist', async () => { + mockOtpVerification.findOne.mockResolvedValue(null); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid or expired OTP'); + + expect(mockOtpVerification.findOne).toHaveBeenCalledWith({ + userId: 'user123', + otp: '123456', + used: false + }); + }); + + test('should return 401 when OTP is already used', async () => { + const usedOtpRecord = { + _id: 'otp123', + userId: 'user123', + otp: '123456', + used: true, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes future + save: jest.fn() + }; + + mockOtpVerification.findOne.mockResolvedValue(null); // Should not find unused OTP + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('Invalid or expired OTP'); + + // Should look for unused OTP + expect(mockOtpVerification.findOne).toHaveBeenCalledWith({ + userId: 'user123', + otp: '123456', + used: false + }); + }); + + test('should return 401 when OTP is expired', async () => { + const expiredOtpRecord = { + _id: 'otp123', + userId: 'user123', + otp: '123456', + used: false, + expiresAt: new Date(Date.now() - 60 * 1000), // 1 minute ago (expired) + save: jest.fn() + }; + + mockOtpVerification.findOne.mockResolvedValue(expiredOtpRecord); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('OTP has expired'); + }); + + test('should successfully verify valid OTP and return reset token', async () => { + const validOtpRecord = { + _id: 'otp123', + userId: 'user123', + otp: '123456', + used: false, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes future + save: jest.fn().mockResolvedValue(true) + }; + + const mockResetToken = 'reset.token.here'; + + mockOtpVerification.findOne.mockResolvedValue(validOtpRecord); + mockJwtSign.mockReturnValue(mockResetToken); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('OTP verified successfully'); + expect(data.resetToken).toBe(mockResetToken); + + // Should mark OTP as used + expect(validOtpRecord.used).toBe(true); + expect(validOtpRecord.save).toHaveBeenCalled(); + + // Should generate reset token with correct payload + expect(mockJwt.sign).toHaveBeenCalledWith( + { userId: 'user123', email: 'john@example.com' }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '15m' } + ); + }); + + test('should handle edge case where OTP expires exactly at verification time', async () => { + const justExpiredOtpRecord = { + _id: 'otp123', + userId: 'user123', + otp: '123456', + used: false, + expiresAt: new Date(Date.now() - 100), // Just expired (100ms ago) + save: jest.fn() + }; + + mockOtpVerification.findOne.mockResolvedValue(justExpiredOtpRecord); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe('OTP has expired'); + }); + + test('should handle OTP record save failure', async () => { + const validOtpRecord = { + _id: 'otp123', + userId: 'user123', + otp: '123456', + used: false, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + save: jest.fn().mockRejectedValue(new Error('Database save failed')) + }; + + mockOtpVerification.findOne.mockResolvedValue(validOtpRecord); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while verifying OTP'); + }); + }); + + describe('Reset Token Generation', () => { + test('should generate reset token with 15 minute expiry', async () => { + const userObj = { + _id: 'user123', + email: 'john@example.com' + }; + + const validOtpRecord = { + _id: 'otp123', + userId: 'user123', + otp: '123456', + used: false, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + save: jest.fn().mockResolvedValue(true) + }; + + mockUser.findOne.mockResolvedValue(userObj); + mockOtpVerification.findOne.mockResolvedValue(validOtpRecord); + mockJwtSign.mockReturnValue('reset.token.here'); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + await POST(request); + + expect(mockJwt.sign).toHaveBeenCalledWith( + { userId: 'user123', email: 'john@example.com' }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '15m' } + ); + }); + + test('should include correct user information in reset token', async () => { + const userObj = { + _id: 'user456', + email: 'jane@example.com' + }; + + const validOtpRecord = { + _id: 'otp456', + userId: 'user456', + otp: '654321', + used: false, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + save: jest.fn().mockResolvedValue(true) + }; + + mockUser.findOne.mockResolvedValue(userObj); + mockOtpVerification.findOne.mockResolvedValue(validOtpRecord); + mockJwtSign.mockReturnValue('different.reset.token'); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'jane@example.com', + otp: '654321' + }) + }); + + await POST(request); + + expect(mockJwt.sign).toHaveBeenCalledWith( + { userId: 'user456', email: 'jane@example.com' }, + 'test-jwt-secret-key-for-testing-purposes-only', + { expiresIn: '15m' } + ); + }); + }); + + describe('Database Operations', () => { + test('should handle database connection errors', async () => { + mockDbConnect.mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while verifying OTP'); + }); + + test('should handle user lookup errors', async () => { + mockUser.findOne.mockRejectedValue(new Error('User query failed')); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '123456' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while verifying OTP'); + }); + }); + + describe('Security Features', () => { + test('should provide generic error message for security', async () => { + const userObj = { + _id: 'user123', + email: 'john@example.com' + }; + + mockUser.findOne.mockResolvedValue(userObj); + mockOtpVerification.findOne.mockResolvedValue(null); + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@example.com', + otp: '999999' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + // Should not reveal specific reason (wrong OTP, already used, etc.) + expect(data.message).toBe('Invalid or expired OTP'); + expect(data.message).not.toContain('does not exist'); + expect(data.message).not.toContain('already used'); + expect(data.message).not.toContain('wrong'); + }); + + test('should handle malformed JSON gracefully', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.message).toBe('An error occurred while verifying OTP'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/models/userSchema.test.ts b/__tests__/models/userSchema.test.ts new file mode 100644 index 00000000..b22cac64 --- /dev/null +++ b/__tests__/models/userSchema.test.ts @@ -0,0 +1,483 @@ +/** + * User Schema Tests + * Tests for User model including password hashing and comparison + */ + +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; +import User, { IUser } from '@/lib/models/userSchema'; + +// Mock bcryptjs +jest.mock('bcryptjs'); +const mockBcrypt = bcrypt as jest.Mocked; + +// Mock mongoose connection +jest.mock('mongoose', () => ({ + ...jest.requireActual('mongoose'), + models: {}, + model: jest.fn() +})); + +describe('User Schema', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Password Hashing', () => { + let mockUser: any; + let mockNext: jest.Mock; + + beforeEach(() => { + mockNext = jest.fn(); + mockUser = { + password: 'plainTextPassword', + isModified: jest.fn(), + isNew: false + }; + + // Mock bcrypt functions + mockBcrypt.genSalt.mockResolvedValue('salt123' as any); + mockBcrypt.hash.mockResolvedValue('hashedPassword123'); + }); + + test('should hash password when password is modified', async () => { + mockUser.isModified.mockReturnValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockBcrypt.genSalt).toHaveBeenCalledWith(10); + expect(mockBcrypt.hash).toHaveBeenCalledWith('plainTextPassword', 'salt123'); + expect(mockUser.password).toBe('hashedPassword123'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should hash password when user is new', async () => { + mockUser.isNew = true; + mockUser.isModified.mockReturnValue(false); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockBcrypt.genSalt).toHaveBeenCalledWith(10); + expect(mockBcrypt.hash).toHaveBeenCalledWith('plainTextPassword', 'salt123'); + expect(mockUser.password).toBe('hashedPassword123'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should not hash password when password is not modified and user is not new', async () => { + mockUser.isModified.mockReturnValue(false); + mockUser.isNew = false; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockBcrypt.genSalt).not.toHaveBeenCalled(); + expect(mockBcrypt.hash).not.toHaveBeenCalled(); + expect(mockUser.password).toBe('plainTextPassword'); // Unchanged + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should not hash password when password is null', async () => { + mockUser.password = null; + mockUser.isModified.mockReturnValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockBcrypt.genSalt).not.toHaveBeenCalled(); + expect(mockBcrypt.hash).not.toHaveBeenCalled(); + expect(mockUser.password).toBeNull(); + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should not hash password when password is undefined', async () => { + mockUser.password = undefined; + mockUser.isModified.mockReturnValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockBcrypt.genSalt).not.toHaveBeenCalled(); + expect(mockBcrypt.hash).not.toHaveBeenCalled(); + expect(mockUser.password).toBeUndefined(); + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should handle bcrypt errors during hashing', async () => { + mockUser.isModified.mockReturnValue(true); + const hashError = new Error('Bcrypt hash failed'); + mockBcrypt.hash.mockRejectedValue(hashError); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockNext).toHaveBeenCalledWith(hashError); + }); + + test('should handle salt generation errors', async () => { + mockUser.isModified.mockReturnValue(true); + const saltError = new Error('Salt generation failed'); + mockBcrypt.genSalt.mockRejectedValue(saltError); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const preHook = userSchema.pre.mock.calls.find((call: any[]) => call[0] === 'save'); + const hashFunction = preHook[1]; + + await hashFunction.call(mockUser, mockNext); + + expect(mockNext).toHaveBeenCalledWith(saltError); + }); + }); + + describe('Password Comparison', () => { + let mockUser: any; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + mockUser = { + password: 'hashedPassword123', + isGoogleUser: false + }; + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + test('should return true when passwords match', async () => { + mockBcrypt.compare.mockResolvedValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'plainTextPassword'); + + expect(result).toBe(true); + expect(mockBcrypt.compare).toHaveBeenCalledWith('plainTextPassword', 'hashedPassword123'); + expect(consoleLogSpy).toHaveBeenCalledWith('Password match result:', true); + }); + + test('should return false when passwords do not match', async () => { + mockBcrypt.compare.mockResolvedValue(false); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'wrongPassword'); + + expect(result).toBe(false); + expect(mockBcrypt.compare).toHaveBeenCalledWith('wrongPassword', 'hashedPassword123'); + expect(consoleLogSpy).toHaveBeenCalledWith('Password match result:', false); + }); + + test('should return false when user has no password set', async () => { + mockUser.password = null; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'anyPassword'); + + expect(result).toBe(false); + expect(mockBcrypt.compare).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No password set for this user'); + }); + + test('should return false when user password is undefined', async () => { + mockUser.password = undefined; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'anyPassword'); + + expect(result).toBe(false); + expect(mockBcrypt.compare).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No password set for this user'); + }); + + test('should return false when user password is empty string', async () => { + mockUser.password = ''; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'anyPassword'); + + expect(result).toBe(false); + expect(mockBcrypt.compare).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No password set for this user'); + }); + + test('should log user type during comparison', async () => { + mockUser.isGoogleUser = true; + mockBcrypt.compare.mockResolvedValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + await comparePasswordMethod.call(mockUser, 'password'); + + expect(consoleLogSpy).toHaveBeenCalledWith('User type:', 'Google user'); + }); + + test('should log regular user type during comparison', async () => { + mockUser.isGoogleUser = false; + mockBcrypt.compare.mockResolvedValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + await comparePasswordMethod.call(mockUser, 'password'); + + expect(consoleLogSpy).toHaveBeenCalledWith('User type:', 'Regular user'); + }); + + test('should log password and hash lengths', async () => { + mockUser.password = 'hashedPassword123'; // 16 characters + mockBcrypt.compare.mockResolvedValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + await comparePasswordMethod.call(mockUser, 'testPassword'); // 12 characters + + expect(consoleLogSpy).toHaveBeenCalledWith('Candidate password length:', 12); + expect(consoleLogSpy).toHaveBeenCalledWith('Stored password hash length:', 16); + }); + + test('should handle bcrypt comparison errors', async () => { + const compareError = new Error('Bcrypt comparison failed'); + mockBcrypt.compare.mockRejectedValue(compareError); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'password'); + + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error comparing passwords:', compareError); + }); + + test('should work with various password lengths and characters', async () => { + const testCases = [ + 'short', + 'averageLengthPassword123', + 'VeryLongPasswordWithSpecialCharacters!@#$%^&*()_+{}:"<>?[];\'.,/`~', + '1234567890', + 'πάσσωορδ', // Unicode characters + 'password with spaces', + '' + ]; + + mockBcrypt.compare.mockResolvedValue(true); + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + for (const password of testCases) { + const result = await comparePasswordMethod.call(mockUser, password); + + if (password === '') { + // Empty passwords should be compared normally + expect(result).toBe(true); + expect(mockBcrypt.compare).toHaveBeenCalledWith('', 'hashedPassword123'); + } else { + expect(result).toBe(true); + expect(mockBcrypt.compare).toHaveBeenCalledWith(password, 'hashedPassword123'); + } + } + }); + }); + + describe('User Schema Methods', () => { + test('should remove password from JSON output', () => { + const mockUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + password: 'hashedPassword', + phone: '+1234567890', + toObject: jest.fn().mockReturnValue({ + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + password: 'hashedPassword', + phone: '+1234567890' + }) + }; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const toJSONMethod = userSchema.methods.toJSON; + + const result = toJSONMethod.call(mockUser); + + expect(result).toEqual({ + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890' + }); + expect(result.password).toBeUndefined(); + }); + + test('should handle toJSON when password is already undefined', () => { + const mockUser = { + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + toObject: jest.fn().mockReturnValue({ + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890' + }) + }; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const toJSONMethod = userSchema.methods.toJSON; + + const result = toJSONMethod.call(mockUser); + + expect(result).toEqual({ + _id: 'user123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890' + }); + expect(result.password).toBeUndefined(); + }); + }); + + describe('Schema Validation', () => { + test('should have required fields defined', () => { + const userSchema = require('@/lib/models/userSchema').default.schema; + const schemaDefinition = userSchema.obj; + + // Required fields + expect(schemaDefinition.firstName.required).toBe(true); + expect(schemaDefinition.lastName.required).toBe(true); + expect(schemaDefinition.email.required).toBe(true); + expect(schemaDefinition.email.unique).toBe(true); + + // Optional fields + expect(schemaDefinition.password.required).toBe(false); + expect(schemaDefinition.phone.required).toBe(false); + expect(schemaDefinition.title.required).toBe(false); + }); + + test('should have correct default values', () => { + const userSchema = require('@/lib/models/userSchema').default.schema; + const schemaDefinition = userSchema.obj; + + expect(schemaDefinition.isGoogleUser.default).toBe(false); + expect(schemaDefinition.profileCompleted.default).toBe(false); + expect(schemaDefinition.isBlocked.default).toBe(false); + expect(schemaDefinition.isDeleted.default).toBe(false); + expect(schemaDefinition.suspension.isSuspended.default).toBe(false); + }); + + test('should have unique and sparse index on googleId', () => { + const userSchema = require('@/lib/models/userSchema').default.schema; + const schemaDefinition = userSchema.obj; + + expect(schemaDefinition.googleId.unique).toBe(true); + expect(schemaDefinition.googleId.sparse).toBe(true); + }); + + test('should have timestamps enabled', () => { + const userSchema = require('@/lib/models/userSchema').default.schema; + + expect(userSchema.options.timestamps).toBe(true); + }); + }); + + describe('Edge Cases and Error Scenarios', () => { + test('should handle null values in comparePassword gracefully', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + const mockUser = { + password: null, + isGoogleUser: false + }; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, null); + + expect(result).toBe(false); + expect(consoleLogSpy).toHaveBeenCalledWith('No password set for this user'); + + consoleLogSpy.mockRestore(); + }); + + test('should handle undefined candidate password', async () => { + mockBcrypt.compare.mockResolvedValue(false); + + const mockUser = { + password: 'hashedPassword', + isGoogleUser: false + }; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, undefined); + + expect(result).toBe(false); + expect(mockBcrypt.compare).toHaveBeenCalledWith(undefined, 'hashedPassword'); + }); + + test('should handle Google user with password set', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + mockBcrypt.compare.mockResolvedValue(true); + + const mockUser = { + password: 'hashedPassword', + isGoogleUser: true + }; + + const userSchema = require('@/lib/models/userSchema').default.schema; + const comparePasswordMethod = userSchema.methods.comparePassword; + + const result = await comparePasswordMethod.call(mockUser, 'correctPassword'); + + expect(result).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith('User type:', 'Google user'); + + consoleLogSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/utils/jwtAuth.test.ts b/__tests__/utils/jwtAuth.test.ts new file mode 100644 index 00000000..8469e2cc --- /dev/null +++ b/__tests__/utils/jwtAuth.test.ts @@ -0,0 +1,494 @@ +/** + * JWT Authentication Utilities Tests + * Tests for JWT token validation and extraction utilities + */ + +import { validateAndExtractUserId, getUserIdFromToken } from '@/utils/jwtAuth'; +import jwt from 'jsonwebtoken'; + +// Mock jsonwebtoken +jest.mock('jsonwebtoken'); +const mockJwt = jwt as jest.Mocked; + +// Mock environment variables +const originalEnv = process.env; + +describe('JWT Authentication Utilities', () => { + beforeAll(() => { + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Environment Variables', () => { + test('should require JWT_SECRET environment variable', () => { + delete process.env.JWT_SECRET; + + expect(() => { + jest.resetModules(); + require('@/utils/jwtAuth'); + }).toThrow('JWT_SECRET environment variable is required'); + + // Restore for other tests + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-purposes-only'; + }); + }); + + describe('validateAndExtractUserId', () => { + const createMockRequest = (authHeader: string | null) => { + const headers = new Headers(); + if (authHeader) { + headers.set('authorization', authHeader); + } + return { + headers: { + get: (name: string) => headers.get(name) + } + } as any; + }; + + describe('Authorization Header Validation', () => { + test('should return invalid when authorization header is missing', () => { + const request = createMockRequest(null); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'No authorization header found' + }); + }); + + test('should return invalid when authorization header does not start with Bearer', () => { + const request = createMockRequest('Basic some-token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Authorization header must start with Bearer' + }); + }); + + test('should return invalid when token is empty after Bearer', () => { + const request = createMockRequest('Bearer '); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Token is empty, null, or undefined' + }); + }); + + test('should return invalid when token is null string', () => { + const request = createMockRequest('Bearer null'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Token is empty, null, or undefined' + }); + }); + + test('should return invalid when token is undefined string', () => { + const request = createMockRequest('Bearer undefined'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Token is empty, null, or undefined' + }); + }); + }); + + describe('JWT Structure Validation', () => { + test('should return invalid when JWT has wrong number of parts', () => { + const request = createMockRequest('Bearer invalid.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Invalid JWT structure: expected 3 parts, got 2' + }); + }); + + test('should return invalid when JWT has too many parts', () => { + const request = createMockRequest('Bearer part1.part2.part3.part4'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Invalid JWT structure: expected 3 parts, got 4' + }); + }); + + test('should return invalid when JWT has only one part', () => { + const request = createMockRequest('Bearer singlepart'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Invalid JWT structure: expected 3 parts, got 1' + }); + }); + }); + + describe('JWT Verification', () => { + test('should successfully validate and extract userId from valid token', () => { + const mockDecoded = { userId: 'user123', email: 'test@example.com' }; + mockJwt.verify.mockReturnValue(mockDecoded as any); + + const request = createMockRequest('Bearer valid.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: true, + userId: 'user123' + }); + + expect(mockJwt.verify).toHaveBeenCalledWith( + 'valid.jwt.token', + 'test-jwt-secret-key-for-testing-purposes-only' + ); + }); + + test('should return invalid when decoded token has no userId', () => { + const mockDecoded = { email: 'test@example.com' }; // Missing userId + mockJwt.verify.mockReturnValue(mockDecoded as any); + + const request = createMockRequest('Bearer valid.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Token does not contain userId' + }); + }); + + test('should handle JWT verification errors', () => { + // Create a proper mock error that matches jwt.JsonWebTokenError behavior + const jwtError = Object.assign(new Error('invalid signature'), { + name: 'JsonWebTokenError' + }); + + // Make it an instance of jwt.JsonWebTokenError for instanceof check + Object.setPrototypeOf(jwtError, jwt.JsonWebTokenError.prototype); + + mockJwt.verify.mockImplementation(() => { + throw jwtError; + }); + + const request = createMockRequest('Bearer invalid.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'JWT Error: invalid signature' + }); + }); + + test('should handle token expiration errors', () => { + // Create a proper mock error that matches jwt.TokenExpiredError behavior + const expiredError = Object.assign(new Error('jwt expired'), { + name: 'TokenExpiredError', + expiredAt: new Date() + }); + + // Make it an instance of jwt.JsonWebTokenError for instanceof check + Object.setPrototypeOf(expiredError, jwt.JsonWebTokenError.prototype); + + mockJwt.verify.mockImplementation(() => { + throw expiredError; + }); + + const request = createMockRequest('Bearer expired.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'JWT Error: jwt expired' + }); + }); + + test('should handle malformed JWT errors', () => { + // Create a proper mock error that matches jwt.JsonWebTokenError behavior + const malformedError = Object.assign(new Error('jwt malformed'), { + name: 'JsonWebTokenError' + }); + + // Make it an instance of jwt.JsonWebTokenError for instanceof check + Object.setPrototypeOf(malformedError, jwt.JsonWebTokenError.prototype); + + mockJwt.verify.mockImplementation(() => { + throw malformedError; + }); + + const request = createMockRequest('Bearer malformed.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'JWT Error: jwt malformed' + }); + }); + + test('should handle generic errors', () => { + const genericError = new Error('Database connection failed'); + mockJwt.verify.mockImplementation(() => { + throw genericError; + }); + + const request = createMockRequest('Bearer valid.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Database connection failed' + }); + }); + + test('should handle unknown errors', () => { + mockJwt.verify.mockImplementation(() => { + throw 'unknown error type'; + }); + + const request = createMockRequest('Bearer valid.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Unknown error occurred' + }); + }); + }); + + describe('Edge Cases', () => { + test('should handle normal Bearer token correctly', () => { + const request = createMockRequest('Bearer valid.jwt.token'); + + const mockDecoded = { userId: 'user123' }; + mockJwt.verify.mockReturnValue(mockDecoded as any); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: true, + userId: 'user123' + }); + }); + + test('should handle Bearer with different casing', () => { + const request = createMockRequest('bearer valid.jwt.token'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Authorization header must start with Bearer' + }); + }); + + test('should handle multiple spaces after Bearer (results in empty token)', () => { + const request = createMockRequest('Bearer valid.jwt.token'); + + // With multiple spaces, split(' ')[1] will be an empty string + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: false, + userId: null, + error: 'Token is empty, null, or undefined' + }); + }); + + test('should handle trailing spaces in header', () => { + const request = createMockRequest('Bearer valid.jwt.token '); + + const mockDecoded = { userId: 'user123' }; + mockJwt.verify.mockReturnValue(mockDecoded as any); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: true, + userId: 'user123' + }); + }); + }); + }); + + describe('getUserIdFromToken (Backward Compatibility)', () => { + const createMockRequest = (authHeader: string | null) => { + const headers = new Headers(); + if (authHeader) { + headers.set('authorization', authHeader); + } + return { + headers: { + get: (name: string) => headers.get(name) + } + } as any; + }; + + test('should return userId when token is valid', () => { + const mockDecoded = { userId: 'user123', email: 'test@example.com' }; + mockJwt.verify.mockReturnValue(mockDecoded as any); + + const request = createMockRequest('Bearer valid.jwt.token'); + + const result = getUserIdFromToken(request); + + expect(result).toBe('user123'); + }); + + test('should return null when token is invalid', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const request = createMockRequest('Bearer invalid.token'); + + const result = getUserIdFromToken(request); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Token validation failed:', + 'Invalid JWT structure: expected 3 parts, got 2' + ); + + consoleErrorSpy.mockRestore(); + }); + + test('should return null when no authorization header', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const request = createMockRequest(null); + + const result = getUserIdFromToken(request); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Token validation failed:', + 'No authorization header found' + ); + + consoleErrorSpy.mockRestore(); + }); + + test('should return null when JWT verification fails', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Create a proper mock error that matches jwt.JsonWebTokenError behavior + const jwtError = Object.assign(new Error('invalid signature'), { + name: 'JsonWebTokenError' + }); + + // Make it an instance of jwt.JsonWebTokenError for instanceof check + Object.setPrototypeOf(jwtError, jwt.JsonWebTokenError.prototype); + + mockJwt.verify.mockImplementation(() => { + throw jwtError; + }); + + const request = createMockRequest('Bearer invalid.jwt.token'); + + const result = getUserIdFromToken(request); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Token validation failed:', + 'JWT Error: invalid signature' + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Real-world Scenarios', () => { + const createMockRequest = (authHeader: string | null) => { + const headers = new Headers(); + if (authHeader) { + headers.set('authorization', authHeader); + } + return { + headers: { + get: (name: string) => headers.get(name) + } + } as any; + }; + + test('should handle valid production-like token', () => { + const mockDecoded = { + userId: '507f1f77bcf86cd799439011', + email: 'user@skillswaphub.com', + name: 'John Doe', + iat: 1642678800, + exp: 1642765200 + }; + mockJwt.verify.mockReturnValue(mockDecoded as any); + + const request = createMockRequest('Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJlbWFpbCI6InVzZXJAc2tpbGxzd2FwaHViLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTY0MjY3ODgwMCwiZXhwIjoxNjQyNzY1MjAwfQ.signature'); + + const result = validateAndExtractUserId(request); + + expect(result).toEqual({ + isValid: true, + userId: '507f1f77bcf86cd799439011' + }); + }); + + test('should handle concurrent validation requests', async () => { + const mockDecoded1 = { userId: 'user123' }; + const mockDecoded2 = { userId: 'user456' }; + + mockJwt.verify + .mockReturnValueOnce(mockDecoded1 as any) + .mockReturnValueOnce(mockDecoded2 as any); + + const request1 = createMockRequest('Bearer token1.jwt.here'); + const request2 = createMockRequest('Bearer token2.jwt.here'); + + const [result1, result2] = await Promise.all([ + Promise.resolve(validateAndExtractUserId(request1)), + Promise.resolve(validateAndExtractUserId(request2)) + ]); + + expect(result1.userId).toBe('user123'); + expect(result2.userId).toBe('user456'); + expect(result1.isValid).toBe(true); + expect(result2.isValid).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js index bd48e444..923f8387 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -51,3 +51,106 @@ jest.mock('next/navigation', () => ({ // Mock environment variables process.env.NODE_ENV = 'test' +process.env.MONGODB_URI = 'mongodb://localhost:27017/test' + +// Add Node.js polyfills for browser-like environment +const { TextEncoder, TextDecoder } = require('util'); +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; + +// Suppress Mongoose Jest warnings +process.env.SUPPRESS_JEST_WARNINGS = 'true'; + +// Mock global Request and Response for Next.js API routes +global.Request = class MockRequest { + constructor(url, init = {}) { + this.url = url; + this.method = init.method || 'GET'; + this.headers = new Headers(init.headers || {}); + this.body = init.body; + this._json = null; + + // Parse JSON body if provided + if (init.body && typeof init.body === 'string') { + try { + this._json = JSON.parse(init.body); + } catch (e) { + // Invalid JSON will throw during json() call + } + } + } + + async json() { + if (this._json !== null) { + return this._json; + } + + if (this.body && typeof this.body === 'string') { + try { + return JSON.parse(this.body); + } catch (e) { + throw new Error('Invalid JSON'); + } + } + + throw new Error('No JSON body'); + } +} + +global.Response = class MockResponse { + constructor(body, init = {}) { + this.body = body; + this.status = init.status || 200; + this.statusText = init.statusText || 'OK'; + this.headers = new Headers(init.headers || {}); + } + + async json() { + return typeof this.body === 'string' ? JSON.parse(this.body) : this.body; + } +} + +// Mock Headers class +global.Headers = class MockHeaders { + constructor(init = {}) { + this.entries = new Map(); + if (init) { + Object.entries(init).forEach(([key, value]) => { + this.entries.set(key.toLowerCase(), value); + }); + } + } + + get(name) { + return this.entries.get(name.toLowerCase()) || null; + } + + set(name, value) { + this.entries.set(name.toLowerCase(), value); + } + + has(name) { + return this.entries.has(name.toLowerCase()); + } + + delete(name) { + return this.entries.delete(name.toLowerCase()); + } +} + +// Mock NextResponse +jest.mock('next/server', () => ({ + NextResponse: { + json: (body, init = {}) => { + const response = new Response(JSON.stringify(body), { + status: init.status || 200, + statusText: init.statusText || 'OK', + headers: { + 'content-type': 'application/json', + ...init.headers + } + }); + return response; + } + } +}));