diff --git a/backend/jest.config.js b/backend/jest.config.js index dc0e711..0c2ea53 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,5 +1,22 @@ module.exports = { - testEnvironment: 'node', + projects: [ + { + displayName: 'unit', + testEnvironment: 'node', + testMatch: ['**/tests/unit/**/*.test.js'], + setupFilesAfterEnv: ['/tests/setup.js'], + testTimeout: 10000, + }, + { + displayName: 'integration', + testEnvironment: 'node', + testMatch: ['**/tests/integration/**/*.test.js'], + setupFilesAfterEnv: ['/tests/integration-setup.js'], + testTimeout: 30000, + }, + ], + // Run tests sequentially to avoid module cache conflicts between unit and integration tests + maxWorkers: 1, coverageDirectory: 'coverage', collectCoverageFrom: [ '**/*.js', @@ -9,10 +26,6 @@ module.exports = { '!jest.config.js' ], coverageReporters: ['text', 'lcov', 'html'], - testMatch: ['**/tests/**/*.test.js'], - setupFilesAfterEnv: ['/tests/setup.js'], - forceExit: true, - testTimeout: 10000, coverageThreshold: { global: { lines: 80, diff --git a/backend/package.json b/backend/package.json index 0645fe7..42a8337 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,10 +12,10 @@ "dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa", "test": "NODE_ENV=test jest", "test:watch": "NODE_ENV=test jest --watch", - "test:coverage": "jest --coverage --forceExit --maxWorkers=4", + "test:coverage": "jest --coverage --maxWorkers=1", "test:unit": "NODE_ENV=test jest tests/unit", "test:integration": "NODE_ENV=test jest tests/integration", - "test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2", + "test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=1", "db:migrate": "sequelize-cli db:migrate", "db:migrate:undo": "sequelize-cli db:migrate:undo", "db:migrate:undo:all": "sequelize-cli db:migrate:undo:all", diff --git a/backend/routes/auth.js b/backend/routes/auth.js index dcc42f9..974fc48 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -553,7 +553,7 @@ router.post( } // Validate the code - if (!user.isVerificationTokenValid(input)) { + if (!user.isVerificationTokenValid(code)) { // Increment failed attempts await user.incrementVerificationAttempts(); diff --git a/backend/tests/integration-setup.js b/backend/tests/integration-setup.js new file mode 100644 index 0000000..c7e041f --- /dev/null +++ b/backend/tests/integration-setup.js @@ -0,0 +1,13 @@ +// Integration test setup +// Integration tests use a real database, so we don't mock DATABASE_URL + +process.env.NODE_ENV = 'test'; + +// Ensure JWT secrets are set for integration tests +process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'; + +// Set other required env vars if not already set +process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || 'test-key'; +process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_key'; diff --git a/backend/tests/integration/auth.integration.test.js b/backend/tests/integration/auth.integration.test.js index 999a901..726c858 100644 --- a/backend/tests/integration/auth.integration.test.js +++ b/backend/tests/integration/auth.integration.test.js @@ -20,6 +20,7 @@ jest.mock('../../middleware/rateLimiter', () => ({ passwordResetRequestLimiter: (req, res, next) => next(), verifyEmailLimiter: (req, res, next) => next(), resendVerificationLimiter: (req, res, next) => next(), + emailVerificationLimiter: (req, res, next) => next(), })); // Mock CSRF protection for tests @@ -225,7 +226,7 @@ describe('Auth Integration Tests', () => { }) .expect(401); - expect(response.body.error).toBe('Invalid credentials'); + expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.'); }); it('should reject login with non-existent email', async () => { @@ -237,7 +238,7 @@ describe('Auth Integration Tests', () => { }) .expect(401); - expect(response.body.error).toBe('Invalid credentials'); + expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.'); }); it('should increment login attempts on failed login', async () => { @@ -421,7 +422,8 @@ describe('Auth Integration Tests', () => { describe('POST /auth/verify-email', () => { let testUser; - let verificationToken; + let verificationCode; + let accessToken; beforeEach(async () => { testUser = await createTestUser({ @@ -430,13 +432,21 @@ describe('Auth Integration Tests', () => { }); await testUser.generateVerificationToken(); await testUser.reload(); - verificationToken = testUser.verificationToken; + verificationCode = testUser.verificationToken; // Now a 6-digit code + + // Generate access token for authentication + accessToken = jwt.sign( + { id: testUser.id, email: testUser.email, jwtVersion: testUser.jwtVersion || 0 }, + process.env.JWT_ACCESS_SECRET || 'test-access-secret', + { expiresIn: '15m' } + ); }); - it('should verify email with valid token', async () => { + it('should verify email with valid code', async () => { const response = await request(app) .post('/auth/verify-email') - .send({ token: verificationToken }) + .set('Cookie', `accessToken=${accessToken}`) + .send({ code: verificationCode }) .expect(200); expect(response.body.message).toBe('Email verified successfully'); @@ -448,13 +458,14 @@ describe('Auth Integration Tests', () => { expect(testUser.verificationToken).toBeNull(); }); - it('should reject verification with invalid token', async () => { + it('should reject verification with invalid code', async () => { const response = await request(app) .post('/auth/verify-email') - .send({ token: 'invalid-token' }) + .set('Cookie', `accessToken=${accessToken}`) + .send({ code: '000000' }) .expect(400); - expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); + expect(response.body.code).toBe('VERIFICATION_INVALID'); }); it('should reject verification for already verified user', async () => { @@ -463,10 +474,11 @@ describe('Auth Integration Tests', () => { const response = await request(app) .post('/auth/verify-email') - .send({ token: verificationToken }) + .set('Cookie', `accessToken=${accessToken}`) + .send({ code: verificationCode }) .expect(400); - expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); + expect(response.body.code).toBe('ALREADY_VERIFIED'); }); }); diff --git a/backend/tests/unit/models/User.verification.test.js b/backend/tests/unit/models/User.verification.test.js index cce0fa8..388e20a 100644 --- a/backend/tests/unit/models/User.verification.test.js +++ b/backend/tests/unit/models/User.verification.test.js @@ -40,6 +40,7 @@ describe('User Model - Email Verification', () => { lastName: 'User', verificationToken: null, verificationTokenExpiry: null, + verificationAttempts: 0, isVerified: false, verifiedAt: null, update: jest.fn().mockImplementation(function(updates) { @@ -53,18 +54,17 @@ describe('User Model - Email Verification', () => { }); describe('generateVerificationToken', () => { - it('should generate a random token and set 24-hour expiry', async () => { - const mockRandomBytes = Buffer.from('a'.repeat(32)); - const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times - - crypto.randomBytes.mockReturnValue(mockRandomBytes); + it('should generate a 6-digit code and set 24-hour expiry', async () => { + const mockCode = 123456; + crypto.randomInt.mockReturnValue(mockCode); await User.prototype.generateVerificationToken.call(mockUser); - expect(crypto.randomBytes).toHaveBeenCalledWith(32); + expect(crypto.randomInt).toHaveBeenCalledWith(100000, 999999); expect(mockUser.update).toHaveBeenCalledWith( expect.objectContaining({ - verificationToken: mockToken + verificationToken: '123456', + verificationAttempts: 0, }) ); @@ -77,40 +77,40 @@ describe('User Model - Email Verification', () => { expect(expiryTime).toBeLessThan(expectedExpiry + 1000); }); - it('should update the user with token and expiry', async () => { - const mockRandomBytes = Buffer.from('b'.repeat(32)); - const mockToken = mockRandomBytes.toString('hex'); - - crypto.randomBytes.mockReturnValue(mockRandomBytes); + it('should update the user with code and expiry', async () => { + const mockCode = 654321; + crypto.randomInt.mockReturnValue(mockCode); const result = await User.prototype.generateVerificationToken.call(mockUser); expect(mockUser.update).toHaveBeenCalledTimes(1); - expect(result.verificationToken).toBe(mockToken); + expect(result.verificationToken).toBe('654321'); expect(result.verificationTokenExpiry).toBeInstanceOf(Date); }); - it('should generate unique tokens on multiple calls', async () => { - const mockRandomBytes1 = Buffer.from('a'.repeat(32)); - const mockRandomBytes2 = Buffer.from('b'.repeat(32)); - - crypto.randomBytes - .mockReturnValueOnce(mockRandomBytes1) - .mockReturnValueOnce(mockRandomBytes2); + it('should generate unique codes on multiple calls', async () => { + crypto.randomInt + .mockReturnValueOnce(111111) + .mockReturnValueOnce(222222); await User.prototype.generateVerificationToken.call(mockUser); - const firstToken = mockUser.update.mock.calls[0][0].verificationToken; + const firstCode = mockUser.update.mock.calls[0][0].verificationToken; await User.prototype.generateVerificationToken.call(mockUser); - const secondToken = mockUser.update.mock.calls[1][0].verificationToken; + const secondCode = mockUser.update.mock.calls[1][0].verificationToken; - expect(firstToken).not.toBe(secondToken); + expect(firstCode).not.toBe(secondCode); }); }); describe('isVerificationTokenValid', () => { + beforeEach(() => { + // Mock timingSafeEqual to do a simple comparison + crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b)); + }); + it('should return true for valid token and non-expired time', () => { - const validToken = 'valid-token-123'; + const validToken = '123456'; const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now mockUser.verificationToken = validToken; @@ -131,25 +131,25 @@ describe('User Model - Email Verification', () => { }); it('should return false for missing expiry', () => { - mockUser.verificationToken = 'valid-token'; + mockUser.verificationToken = '123456'; mockUser.verificationTokenExpiry = null; - const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token'); + const result = User.prototype.isVerificationTokenValid.call(mockUser, '123456'); expect(result).toBe(false); }); it('should return false for mismatched token', () => { - mockUser.verificationToken = 'correct-token'; + mockUser.verificationToken = '123456'; mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); - const result = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token'); + const result = User.prototype.isVerificationTokenValid.call(mockUser, '654321'); expect(result).toBe(false); }); it('should return false for expired token', () => { - const validToken = 'valid-token-123'; + const validToken = '123456'; const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago mockUser.verificationToken = validToken; @@ -161,7 +161,7 @@ describe('User Model - Email Verification', () => { }); it('should return false for token expiring in the past by 1 second', () => { - const validToken = 'valid-token-123'; + const validToken = '123456'; const pastExpiry = new Date(Date.now() - 1000); // 1 second ago mockUser.verificationToken = validToken; @@ -173,7 +173,7 @@ describe('User Model - Email Verification', () => { }); it('should handle edge case of token expiring exactly now', () => { - const validToken = 'valid-token-123'; + const validToken = '123456'; // Set expiry 1ms in the future to handle timing precision const nowExpiry = new Date(Date.now() + 1); @@ -187,7 +187,7 @@ describe('User Model - Email Verification', () => { }); it('should handle string dates correctly', () => { - const validToken = 'valid-token-123'; + const validToken = '123456'; const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date mockUser.verificationToken = validToken; @@ -201,7 +201,7 @@ describe('User Model - Email Verification', () => { describe('verifyEmail', () => { it('should mark user as verified and clear token fields', async () => { - mockUser.verificationToken = 'some-token'; + mockUser.verificationToken = '123456'; mockUser.verificationTokenExpiry = new Date(); await User.prototype.verifyEmail.call(mockUser); @@ -245,19 +245,22 @@ describe('User Model - Email Verification', () => { }); describe('Complete verification flow', () => { + beforeEach(() => { + crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b)); + }); + it('should complete full verification flow successfully', async () => { - // Step 1: Generate verification token - const mockRandomBytes = Buffer.from('c'.repeat(32)); - const mockToken = mockRandomBytes.toString('hex'); - crypto.randomBytes.mockReturnValue(mockRandomBytes); + // Step 1: Generate verification code + const mockCode = 999888; + crypto.randomInt.mockReturnValue(mockCode); await User.prototype.generateVerificationToken.call(mockUser); - expect(mockUser.verificationToken).toBe(mockToken); + expect(mockUser.verificationToken).toBe('999888'); expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date); - // Step 2: Validate token - const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken); + // Step 2: Validate code + const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '999888'); expect(isValid).toBe(true); // Step 3: Verify email @@ -270,25 +273,23 @@ describe('User Model - Email Verification', () => { }); it('should fail verification with wrong token', async () => { - // Generate token - const mockToken = 'd'.repeat(64); - const mockRandomBytes = Buffer.from('d'.repeat(32)); - crypto.randomBytes.mockReturnValue(mockRandomBytes); + // Generate code + crypto.randomInt.mockReturnValue(123456); await User.prototype.generateVerificationToken.call(mockUser); - // Try to validate with wrong token - const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token'); + // Try to validate with wrong code + const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '654321'); expect(isValid).toBe(false); }); it('should fail verification with expired token', async () => { // Manually set an expired token - mockUser.verificationToken = 'expired-token'; + mockUser.verificationToken = '123456'; mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago - const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'expired-token'); + const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '123456'); expect(isValid).toBe(false); }); diff --git a/backend/tests/unit/routes/auth.test.js b/backend/tests/unit/routes/auth.test.js index 29ad83f..64ba1b1 100644 --- a/backend/tests/unit/routes/auth.test.js +++ b/backend/tests/unit/routes/auth.test.js @@ -45,10 +45,15 @@ jest.mock('../../../middleware/rateLimiter', () => ({ loginLimiter: (req, res, next) => next(), registerLimiter: (req, res, next) => next(), passwordResetLimiter: (req, res, next) => next(), + emailVerificationLimiter: (req, res, next) => next(), })); jest.mock('../../../middleware/auth', () => ({ optionalAuth: (req, res, next) => next(), + authenticateToken: (req, res, next) => { + req.user = { id: 'user-123' }; + next(); + }, })); jest.mock('../../../services/email', () => ({ @@ -290,7 +295,7 @@ describe('Auth Routes', () => { }); expect(response.status).toBe(401); - expect(response.body.error).toBe('Invalid credentials'); + expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.'); }); it('should reject login with invalid password', async () => { @@ -311,7 +316,7 @@ describe('Auth Routes', () => { }); expect(response.status).toBe(401); - expect(response.body.error).toBe('Invalid credentials'); + expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.'); expect(mockUser.incLoginAttempts).toHaveBeenCalled(); }); @@ -536,95 +541,147 @@ describe('Auth Routes', () => { }); describe('POST /auth/verify-email', () => { - it('should verify email with valid token', async () => { + it('should verify email with valid 6-digit code', async () => { const mockUser = { - id: 1, + id: 'user-123', email: 'test@example.com', isVerified: false, - verificationToken: 'valid-token', + verificationToken: '123456', + verificationTokenExpiry: new Date(Date.now() + 3600000), // 1 hour from now + verificationAttempts: 0, + isVerificationLocked: jest.fn().mockReturnValue(false), isVerificationTokenValid: jest.fn().mockReturnValue(true), verifyEmail: jest.fn().mockResolvedValue() }; - User.findOne.mockResolvedValue(mockUser); + User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/auth/verify-email') - .send({ token: 'valid-token' }); + .send({ code: '123456' }); expect(response.status).toBe(200); expect(response.body.message).toBe('Email verified successfully'); expect(response.body.user).toMatchObject({ - id: 1, + id: 'user-123', email: 'test@example.com', isVerified: true }); expect(mockUser.verifyEmail).toHaveBeenCalled(); }); - it('should reject missing token', async () => { + it('should reject missing code', async () => { const response = await request(app) .post('/auth/verify-email') .send({}); expect(response.status).toBe(400); - expect(response.body.error).toBe('Verification token required'); - expect(response.body.code).toBe('TOKEN_REQUIRED'); + expect(response.body.error).toBe('Verification code required'); + expect(response.body.code).toBe('CODE_REQUIRED'); }); - it('should reject invalid token', async () => { - User.findOne.mockResolvedValue(null); + it('should reject invalid code format (not 6 digits)', async () => { + const response = await request(app) + .post('/auth/verify-email') + .send({ code: '12345' }); // Only 5 digits + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Verification code must be 6 digits'); + expect(response.body.code).toBe('INVALID_CODE_FORMAT'); + }); + + it('should reject when user not found', async () => { + User.findByPk.mockResolvedValue(null); const response = await request(app) .post('/auth/verify-email') - .send({ token: 'invalid-token' }); + .send({ code: '123456' }); - expect(response.status).toBe(400); - expect(response.body.error).toBe('Invalid verification token'); - expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); + expect(response.status).toBe(404); + expect(response.body.error).toBe('User not found'); + expect(response.body.code).toBe('USER_NOT_FOUND'); }); it('should reject already verified user', async () => { const mockUser = { - id: 1, + id: 'user-123', isVerified: true }; - User.findOne.mockResolvedValue(mockUser); + User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/auth/verify-email') - .send({ token: 'some-token' }); + .send({ code: '123456' }); expect(response.status).toBe(400); expect(response.body.error).toBe('Email already verified'); expect(response.body.code).toBe('ALREADY_VERIFIED'); }); - it('should reject expired token', async () => { + it('should reject when too many verification attempts', async () => { const mockUser = { - id: 1, + id: 'user-123', isVerified: false, - isVerificationTokenValid: jest.fn().mockReturnValue(false) + isVerificationLocked: jest.fn().mockReturnValue(true) }; - User.findOne.mockResolvedValue(mockUser); + User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/auth/verify-email') - .send({ token: 'expired-token' }); + .send({ code: '123456' }); + + expect(response.status).toBe(429); + expect(response.body.error).toContain('Too many verification attempts'); + expect(response.body.code).toBe('TOO_MANY_ATTEMPTS'); + }); + + it('should reject when no verification code exists', async () => { + const mockUser = { + id: 'user-123', + isVerified: false, + verificationToken: null, + isVerificationLocked: jest.fn().mockReturnValue(false) + }; + + User.findByPk.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/auth/verify-email') + .send({ code: '123456' }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('No verification code found'); + expect(response.body.code).toBe('NO_CODE'); + }); + + it('should reject expired verification code', async () => { + const mockUser = { + id: 'user-123', + isVerified: false, + verificationToken: '123456', + verificationTokenExpiry: new Date(Date.now() - 3600000), // 1 hour ago (expired) + isVerificationLocked: jest.fn().mockReturnValue(false) + }; + + User.findByPk.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/auth/verify-email') + .send({ code: '123456' }); expect(response.status).toBe(400); expect(response.body.error).toContain('expired'); - expect(response.body.code).toBe('VERIFICATION_TOKEN_EXPIRED'); + expect(response.body.code).toBe('VERIFICATION_EXPIRED'); }); it('should handle verification errors', async () => { - User.findOne.mockRejectedValue(new Error('Database error')); + User.findByPk.mockRejectedValue(new Error('Database error')); const response = await request(app) .post('/auth/verify-email') - .send({ token: 'some-token' }); + .send({ code: '123456' }); expect(response.status).toBe(500); expect(response.body.error).toBe('Email verification failed. Please try again.'); @@ -835,6 +892,48 @@ describe('Auth Routes', () => { }); }); + describe('GET /auth/status', () => { + it('should return authenticated true when user is logged in', async () => { + // The optionalAuth middleware sets req.user if authenticated + // We need to modify the mock for this specific test + const mockUser = { + id: 1, + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isVerified: true + }; + + // Create a custom app for this test with user set + const statusApp = express(); + statusApp.use(express.json()); + statusApp.use((req, res, next) => { + req.user = mockUser; + next(); + }); + statusApp.use('/auth', authRoutes); + + const response = await request(statusApp) + .get('/auth/status'); + + expect(response.status).toBe(200); + expect(response.body.authenticated).toBe(true); + expect(response.body.user).toMatchObject({ + id: 1, + email: 'test@example.com' + }); + }); + + it('should return authenticated false when user is not logged in', async () => { + const response = await request(app) + .get('/auth/status'); + + expect(response.status).toBe(200); + expect(response.body.authenticated).toBe(false); + expect(response.body.user).toBeUndefined(); + }); + }); + describe('POST /auth/forgot-password', () => { it('should send password reset email for existing user', async () => { const mockUser = { diff --git a/backend/tests/unit/routes/conditionChecks.test.js b/backend/tests/unit/routes/conditionChecks.test.js new file mode 100644 index 0000000..9443206 --- /dev/null +++ b/backend/tests/unit/routes/conditionChecks.test.js @@ -0,0 +1,328 @@ +const request = require('supertest'); +const express = require('express'); + +// Mock dependencies +jest.mock('../../../middleware/auth', () => ({ + authenticateToken: (req, res, next) => { + req.user = { id: 'user-123' }; + next(); + }, +})); + +jest.mock('../../../services/conditionCheckService', () => ({ + submitConditionCheck: jest.fn(), + getConditionChecks: jest.fn(), + getConditionCheckTimeline: jest.fn(), + getAvailableChecks: jest.fn(), +})); + +jest.mock('../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + withRequestId: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + })), +})); + +jest.mock('../../../utils/s3KeyValidator', () => ({ + validateS3Keys: jest.fn().mockReturnValue({ valid: true }), +})); + +jest.mock('../../../config/imageLimits', () => ({ + IMAGE_LIMITS: { conditionChecks: 10 }, +})); + +const ConditionCheckService = require('../../../services/conditionCheckService'); +const { validateS3Keys } = require('../../../utils/s3KeyValidator'); +const conditionCheckRoutes = require('../../../routes/conditionChecks'); + +const app = express(); +app.use(express.json()); +app.use('/condition-checks', conditionCheckRoutes); + +// Error handler +app.use((err, req, res, next) => { + res.status(500).json({ error: err.message }); +}); + +describe('Condition Check Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /condition-checks/:rentalId', () => { + const validConditionCheck = { + checkType: 'pre_rental', + notes: 'Item in good condition', + imageFilenames: ['condition-checks/uuid1.jpg', 'condition-checks/uuid2.jpg'], + }; + + it('should submit a condition check successfully', async () => { + const mockConditionCheck = { + id: 'check-1', + rentalId: 'rental-123', + checkType: 'pre_rental', + notes: 'Item in good condition', + imageFilenames: validConditionCheck.imageFilenames, + submittedBy: 'user-123', + createdAt: new Date().toISOString(), + }; + + ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck); + + const response = await request(app) + .post('/condition-checks/rental-123') + .send(validConditionCheck); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.conditionCheck).toMatchObject({ + id: 'check-1', + checkType: 'pre_rental', + }); + expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith( + 'rental-123', + 'pre_rental', + 'user-123', + validConditionCheck.imageFilenames, + 'Item in good condition' + ); + }); + + it('should handle empty image array', async () => { + const mockConditionCheck = { + id: 'check-1', + rentalId: 'rental-123', + checkType: 'post_rental', + imageFilenames: [], + }; + + ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck); + + const response = await request(app) + .post('/condition-checks/rental-123') + .send({ + checkType: 'post_rental', + notes: 'No photos', + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith( + 'rental-123', + 'post_rental', + 'user-123', + [], + 'No photos' + ); + }); + + it('should reject invalid S3 keys', async () => { + validateS3Keys.mockReturnValueOnce({ + valid: false, + error: 'Invalid S3 key format', + invalidKeys: ['invalid-key'], + }); + + const response = await request(app) + .post('/condition-checks/rental-123') + .send({ + checkType: 'pre_rental', + imageFilenames: ['invalid-key'], + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Invalid S3 key format'); + expect(response.body.details).toContain('invalid-key'); + }); + + it('should handle service errors', async () => { + ConditionCheckService.submitConditionCheck.mockRejectedValue( + new Error('Rental not found') + ); + + const response = await request(app) + .post('/condition-checks/rental-123') + .send(validConditionCheck); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Rental not found'); + }); + + it('should handle non-array imageFilenames gracefully', async () => { + const mockConditionCheck = { + id: 'check-1', + rentalId: 'rental-123', + checkType: 'pre_rental', + imageFilenames: [], + }; + + ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck); + + const response = await request(app) + .post('/condition-checks/rental-123') + .send({ + checkType: 'pre_rental', + imageFilenames: 'not-an-array', + }); + + expect(response.status).toBe(201); + // Should convert to empty array + expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith( + 'rental-123', + 'pre_rental', + 'user-123', + [], + undefined + ); + }); + }); + + describe('GET /condition-checks/:rentalId', () => { + it('should return condition checks for a rental', async () => { + const mockChecks = [ + { + id: 'check-1', + checkType: 'pre_rental', + notes: 'Good condition', + createdAt: '2024-01-01T00:00:00Z', + }, + { + id: 'check-2', + checkType: 'post_rental', + notes: 'Minor wear', + createdAt: '2024-01-15T00:00:00Z', + }, + ]; + + ConditionCheckService.getConditionChecks.mockResolvedValue(mockChecks); + + const response = await request(app) + .get('/condition-checks/rental-123'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.conditionChecks).toHaveLength(2); + expect(response.body.conditionChecks[0].checkType).toBe('pre_rental'); + expect(ConditionCheckService.getConditionChecks).toHaveBeenCalledWith('rental-123'); + }); + + it('should return empty array when no checks exist', async () => { + ConditionCheckService.getConditionChecks.mockResolvedValue([]); + + const response = await request(app) + .get('/condition-checks/rental-456'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.conditionChecks).toHaveLength(0); + }); + + it('should handle service errors', async () => { + ConditionCheckService.getConditionChecks.mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .get('/condition-checks/rental-123'); + + expect(response.status).toBe(500); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Failed to fetch condition checks'); + }); + }); + + describe('GET /condition-checks/:rentalId/timeline', () => { + it('should return condition check timeline', async () => { + const mockTimeline = { + rental: { id: 'rental-123', status: 'completed' }, + checks: [ + { type: 'pre_rental', status: 'completed', completedAt: '2024-01-01' }, + { type: 'post_rental', status: 'pending', completedAt: null }, + ], + }; + + ConditionCheckService.getConditionCheckTimeline.mockResolvedValue(mockTimeline); + + const response = await request(app) + .get('/condition-checks/rental-123/timeline'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.timeline).toMatchObject(mockTimeline); + expect(ConditionCheckService.getConditionCheckTimeline).toHaveBeenCalledWith('rental-123'); + }); + + it('should handle service errors', async () => { + ConditionCheckService.getConditionCheckTimeline.mockRejectedValue( + new Error('Rental not found') + ); + + const response = await request(app) + .get('/condition-checks/rental-123/timeline'); + + expect(response.status).toBe(500); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Rental not found'); + }); + }); + + describe('GET /condition-checks', () => { + it('should return available checks for current user', async () => { + const mockAvailableChecks = [ + { + rentalId: 'rental-1', + itemName: 'Camera', + checkType: 'pre_rental', + dueDate: '2024-01-10', + }, + { + rentalId: 'rental-2', + itemName: 'Laptop', + checkType: 'post_rental', + dueDate: '2024-01-15', + }, + ]; + + ConditionCheckService.getAvailableChecks.mockResolvedValue(mockAvailableChecks); + + const response = await request(app) + .get('/condition-checks'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.availableChecks).toHaveLength(2); + expect(response.body.availableChecks[0].itemName).toBe('Camera'); + expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123'); + }); + + it('should return empty array when no checks available', async () => { + ConditionCheckService.getAvailableChecks.mockResolvedValue([]); + + const response = await request(app) + .get('/condition-checks'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.availableChecks).toHaveLength(0); + }); + + it('should handle service errors', async () => { + ConditionCheckService.getAvailableChecks.mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .get('/condition-checks'); + + expect(response.status).toBe(500); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Failed to fetch available checks'); + }); + }); +}); diff --git a/backend/tests/unit/routes/forum.test.js b/backend/tests/unit/routes/forum.test.js new file mode 100644 index 0000000..fcebb44 --- /dev/null +++ b/backend/tests/unit/routes/forum.test.js @@ -0,0 +1,813 @@ +const request = require('supertest'); +const express = require('express'); + +// Mock dependencies before requiring the route +jest.mock('../../../models', () => ({ + ForumPost: { + findAndCountAll: jest.fn(), + findByPk: jest.fn(), + findOne: jest.fn(), + findAll: jest.fn(), + create: jest.fn(), + }, + ForumComment: { + findAll: jest.fn(), + findByPk: jest.fn(), + create: jest.fn(), + count: jest.fn(), + destroy: jest.fn(), + }, + PostTag: { + findAll: jest.fn(), + findOrCreate: jest.fn(), + create: jest.fn(), + destroy: jest.fn(), + }, + User: { + findByPk: jest.fn(), + }, + sequelize: { + transaction: jest.fn(() => ({ + commit: jest.fn(), + rollback: jest.fn(), + })), + }, +})); + +jest.mock('sequelize', () => ({ + Op: { + or: Symbol('or'), + iLike: Symbol('iLike'), + in: Symbol('in'), + ne: Symbol('ne'), + }, + fn: jest.fn((name, col) => ({ fn: name, col })), + col: jest.fn((name) => ({ col: name })), +})); + +jest.mock('../../../middleware/auth', () => ({ + authenticateToken: (req, res, next) => { + req.user = { id: 'user-123', role: 'user', isVerified: true }; + next(); + }, + requireAdmin: (req, res, next) => { + if (req.user && req.user.role === 'admin') { + next(); + } else { + res.status(403).json({ error: 'Admin access required' }); + } + }, + optionalAuth: (req, res, next) => next(), +})); + +jest.mock('../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + withRequestId: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + })), +})); + +jest.mock('../../../services/email', () => ({ + forum: { + sendNewPostNotification: jest.fn().mockResolvedValue(), + sendNewCommentNotification: jest.fn().mockResolvedValue(), + sendAnswerAcceptedNotification: jest.fn().mockResolvedValue(), + sendReplyNotification: jest.fn().mockResolvedValue(), + }, +})); + +jest.mock('../../../services/googleMapsService', () => ({ + geocodeAddress: jest.fn().mockResolvedValue({ lat: 40.7128, lng: -74.006 }), +})); + +jest.mock('../../../services/locationService', () => ({ + getOrCreateLocation: jest.fn().mockResolvedValue({ id: 'loc-123' }), +})); + +jest.mock('../../../utils/s3KeyValidator', () => ({ + validateS3Keys: jest.fn().mockReturnValue({ valid: true }), +})); + +jest.mock('../../../config/imageLimits', () => ({ + IMAGE_LIMITS: { forum: 10 }, +})); + +const { ForumPost, ForumComment, PostTag, User } = require('../../../models'); +const forumRoutes = require('../../../routes/forum'); + +const app = express(); +app.use(express.json()); +app.use('/forum', forumRoutes); + +// Error handler +app.use((err, req, res, next) => { + res.status(500).json({ error: err.message }); +}); + +describe('Forum Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /forum/posts', () => { + it('should return paginated posts', async () => { + const mockPosts = [ + { + id: 'post-1', + title: 'Test Post', + content: 'Test content', + category: 'question', + status: 'open', + commentCount: 5, + viewCount: 100, + author: { id: 'user-1', firstName: 'John', lastName: 'Doe' }, + tags: [{ id: 'tag-1', name: 'javascript' }], + toJSON: function() { return this; } + }, + ]; + + ForumPost.findAndCountAll.mockResolvedValue({ + count: 1, + rows: mockPosts, + }); + + const response = await request(app) + .get('/forum/posts') + .query({ page: 1, limit: 20 }); + + expect(response.status).toBe(200); + expect(response.body.posts).toHaveLength(1); + expect(response.body.posts[0].title).toBe('Test Post'); + expect(response.body.totalPages).toBe(1); + expect(response.body.currentPage).toBe(1); + expect(response.body.totalPosts).toBe(1); + }); + + it('should filter posts by category', async () => { + ForumPost.findAndCountAll.mockResolvedValue({ + count: 0, + rows: [], + }); + + const response = await request(app) + .get('/forum/posts') + .query({ category: 'question' }); + + expect(response.status).toBe(200); + expect(ForumPost.findAndCountAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + category: 'question', + }), + }) + ); + }); + + it('should search posts by title and content', async () => { + ForumPost.findAndCountAll.mockResolvedValue({ + count: 0, + rows: [], + }); + + const response = await request(app) + .get('/forum/posts') + .query({ search: 'javascript' }); + + expect(response.status).toBe(200); + expect(ForumPost.findAndCountAll).toHaveBeenCalled(); + }); + + it('should sort posts by different criteria', async () => { + ForumPost.findAndCountAll.mockResolvedValue({ + count: 0, + rows: [], + }); + + const response = await request(app) + .get('/forum/posts') + .query({ sort: 'comments' }); + + expect(response.status).toBe(200); + expect(ForumPost.findAndCountAll).toHaveBeenCalledWith( + expect.objectContaining({ + order: expect.arrayContaining([ + ['commentCount', 'DESC'], + ]), + }) + ); + }); + + it('should handle database errors', async () => { + ForumPost.findAndCountAll.mockRejectedValue(new Error('Database error')); + + const response = await request(app) + .get('/forum/posts'); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /forum/posts/:id', () => { + it('should return a single post with comments', async () => { + const mockPost = { + id: 'post-1', + title: 'Test Post', + content: 'Test content', + viewCount: 10, + isDeleted: false, + comments: [], + increment: jest.fn().mockResolvedValue(), + toJSON: function() { + const { increment, toJSON, ...rest } = this; + return rest; + }, + author: { id: 'user-1', firstName: 'John', lastName: 'Doe', role: 'user' }, + tags: [], + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .get('/forum/posts/post-1'); + + expect(response.status).toBe(200); + expect(response.body.title).toBe('Test Post'); + expect(mockPost.increment).toHaveBeenCalledWith('viewCount', { silent: true }); + }); + + it('should return 404 for non-existent post', async () => { + ForumPost.findByPk.mockResolvedValue(null); + + const response = await request(app) + .get('/forum/posts/non-existent'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Post not found'); + }); + + it('should return 404 for deleted post (non-admin)', async () => { + const mockPost = { + id: 'post-1', + isDeleted: true, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .get('/forum/posts/post-1'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Post not found'); + }); + }); + + describe('POST /forum/posts', () => { + const validPostData = { + title: 'New Forum Post', + content: 'This is the content of the post', + category: 'question', + tags: ['javascript', 'react'], + }; + + it('should create a new post successfully', async () => { + const mockCreatedPost = { + id: 'new-post-id', + title: 'New Forum Post', + content: 'This is the content of the post', + category: 'question', + authorId: 'user-123', + status: 'open', + }; + + const mockPostWithDetails = { + ...mockCreatedPost, + author: { id: 'user-123', firstName: 'John', lastName: 'Doe' }, + tags: [{ id: 'tag-1', tagName: 'javascript' }], + toJSON: function() { return this; }, + }; + + ForumPost.create.mockResolvedValue(mockCreatedPost); + // After create, findByPk is called to get post with details + ForumPost.findByPk.mockResolvedValue(mockPostWithDetails); + + const response = await request(app) + .post('/forum/posts') + .send(validPostData); + + expect(response.status).toBe(201); + expect(ForumPost.create).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'New Forum Post', + content: 'This is the content of the post', + category: 'question', + authorId: 'user-123', + }) + ); + }); + + it('should handle Sequelize validation error for missing title', async () => { + const validationError = new Error('Validation error'); + validationError.name = 'SequelizeValidationError'; + ForumPost.create.mockRejectedValue(validationError); + + const response = await request(app) + .post('/forum/posts') + .send({ content: 'Content without title', category: 'question' }); + + expect(response.status).toBe(500); + }); + + it('should handle Sequelize validation error for missing content', async () => { + const validationError = new Error('Validation error'); + validationError.name = 'SequelizeValidationError'; + ForumPost.create.mockRejectedValue(validationError); + + const response = await request(app) + .post('/forum/posts') + .send({ title: 'Title without content', category: 'question' }); + + expect(response.status).toBe(500); + }); + + it('should handle Sequelize validation error for missing category', async () => { + const validationError = new Error('Validation error'); + validationError.name = 'SequelizeValidationError'; + ForumPost.create.mockRejectedValue(validationError); + + const response = await request(app) + .post('/forum/posts') + .send({ title: 'Title', content: 'Content' }); + + expect(response.status).toBe(500); + }); + + it('should handle Sequelize validation error for invalid category', async () => { + const validationError = new Error('Validation error'); + validationError.name = 'SequelizeValidationError'; + ForumPost.create.mockRejectedValue(validationError); + + const response = await request(app) + .post('/forum/posts') + .send({ title: 'Title', content: 'Content', category: 'invalid' }); + + expect(response.status).toBe(500); + }); + + it('should handle Sequelize validation error for title too short', async () => { + const validationError = new Error('Validation error'); + validationError.name = 'SequelizeValidationError'; + ForumPost.create.mockRejectedValue(validationError); + + const response = await request(app) + .post('/forum/posts') + .send({ title: 'Hi', content: 'Content', category: 'question' }); + + expect(response.status).toBe(500); + }); + }); + + describe('PUT /forum/posts/:id', () => { + it('should update own post successfully', async () => { + const mockPost = { + id: 'post-1', + authorId: 'user-123', + title: 'Original Title', + content: 'Original content', + isDeleted: false, + setTags: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + reload: jest.fn().mockResolvedValue(), + toJSON: function() { return this; }, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + PostTag.findOrCreate.mockResolvedValue([{ id: 'tag-1', name: 'updated' }]); + + const response = await request(app) + .put('/forum/posts/post-1') + .send({ title: 'Updated Title', content: 'Updated content' }); + + expect(response.status).toBe(200); + expect(mockPost.update).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Updated Title', + content: 'Updated content', + }) + ); + }); + + it('should return 404 for non-existent post', async () => { + ForumPost.findByPk.mockResolvedValue(null); + + const response = await request(app) + .put('/forum/posts/non-existent') + .send({ title: 'Updated' }); + + expect(response.status).toBe(404); + }); + + it('should return 403 when updating other users post', async () => { + const mockPost = { + id: 'post-1', + authorId: 'other-user', + isDeleted: false, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .put('/forum/posts/post-1') + .send({ title: 'Updated' }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Unauthorized'); + }); + }); + + describe('DELETE /forum/posts/:id', () => { + it('should hard delete own post', async () => { + const mockPost = { + id: 'post-1', + authorId: 'user-123', + isDeleted: false, + destroy: jest.fn().mockResolvedValue(), + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .delete('/forum/posts/post-1'); + + expect(response.status).toBe(204); + expect(mockPost.destroy).toHaveBeenCalled(); + }); + + it('should return 404 for non-existent post', async () => { + ForumPost.findByPk.mockResolvedValue(null); + + const response = await request(app) + .delete('/forum/posts/non-existent'); + + expect(response.status).toBe(404); + }); + + it('should return 403 when deleting other users post', async () => { + const mockPost = { + id: 'post-1', + authorId: 'other-user', + isDeleted: false, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .delete('/forum/posts/post-1'); + + expect(response.status).toBe(403); + }); + }); + + describe('POST /forum/posts/:id/comments', () => { + it('should add a comment to a post', async () => { + const mockPost = { + id: 'post-1', + authorId: 'post-author', + isDeleted: false, + status: 'open', + increment: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + }; + + const mockCreatedComment = { + id: 'comment-1', + content: 'Great post!', + authorId: 'user-123', + postId: 'post-1', + }; + + const mockCommentWithDetails = { + ...mockCreatedComment, + author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, + toJSON: function() { return this; }, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + ForumComment.create.mockResolvedValue(mockCreatedComment); + // After create, findByPk is called to get comment with details + ForumComment.findByPk.mockResolvedValue(mockCommentWithDetails); + + const response = await request(app) + .post('/forum/posts/post-1/comments') + .send({ content: 'Great post!' }); + + expect(response.status).toBe(201); + expect(ForumComment.create).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Great post!', + authorId: 'user-123', + postId: 'post-1', + }) + ); + expect(mockPost.increment).toHaveBeenCalledWith('commentCount'); + }); + + it('should handle Sequelize validation error for missing content', async () => { + const mockPost = { + id: 'post-1', + status: 'open', + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const validationError = new Error('Validation error'); + validationError.name = 'SequelizeValidationError'; + ForumComment.create.mockRejectedValue(validationError); + + const response = await request(app) + .post('/forum/posts/post-1/comments') + .send({}); + + expect(response.status).toBe(500); + }); + + it('should return 404 for non-existent post', async () => { + ForumPost.findByPk.mockResolvedValue(null); + + const response = await request(app) + .post('/forum/posts/non-existent/comments') + .send({ content: 'Comment' }); + + expect(response.status).toBe(404); + }); + + it('should return 403 when commenting on closed post', async () => { + const mockPost = { + id: 'post-1', + isDeleted: false, + status: 'closed', + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .post('/forum/posts/post-1/comments') + .send({ content: 'Comment' }); + + expect(response.status).toBe(403); + expect(response.body.error).toContain('closed'); + }); + + it('should support replying to another comment', async () => { + const mockPost = { + id: 'post-1', + authorId: 'post-author', + isDeleted: false, + status: 'open', + increment: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + }; + + const mockParentComment = { + id: 'parent-comment', + postId: 'post-1', + authorId: 'other-user', + isDeleted: false, + }; + + const mockCreatedReply = { + id: 'reply-1', + content: 'Reply to comment', + parentCommentId: 'parent-comment', + authorId: 'user-123', + postId: 'post-1', + }; + + const mockReplyWithDetails = { + ...mockCreatedReply, + author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, + toJSON: function() { return this; }, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + // First findByPk call checks parent comment, second gets created comment with details + ForumComment.findByPk + .mockResolvedValueOnce(mockParentComment) + .mockResolvedValueOnce(mockReplyWithDetails); + ForumComment.create.mockResolvedValue(mockCreatedReply); + + const response = await request(app) + .post('/forum/posts/post-1/comments') + .send({ content: 'Reply to comment', parentCommentId: 'parent-comment' }); + + expect(response.status).toBe(201); + expect(ForumComment.create).toHaveBeenCalledWith( + expect.objectContaining({ + parentCommentId: 'parent-comment', + }) + ); + }); + }); + + describe('GET /forum/my-posts', () => { + it('should return authenticated users posts', async () => { + const mockPosts = [ + { + id: 'post-1', + title: 'My Post', + authorId: 'user-123', + toJSON: function() { return this; }, + }, + ]; + + ForumPost.findAll.mockResolvedValue(mockPosts); + + const response = await request(app) + .get('/forum/my-posts'); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(ForumPost.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + authorId: 'user-123', + }), + }) + ); + }); + }); + + describe('GET /forum/tags', () => { + it('should return all tags', async () => { + const mockTags = [ + { tagName: 'javascript', count: 10 }, + { tagName: 'react', count: 5 }, + ]; + + PostTag.findAll.mockResolvedValue(mockTags); + + const response = await request(app) + .get('/forum/tags'); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0].tagName).toBe('javascript'); + }); + }); + + describe('PATCH /forum/posts/:id/status', () => { + it('should update post status by author', async () => { + const mockPost = { + id: 'post-1', + authorId: 'user-123', + status: 'open', + isDeleted: false, + update: jest.fn().mockResolvedValue(), + reload: jest.fn().mockResolvedValue(), + toJSON: function() { return this; }, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .patch('/forum/posts/post-1/status') + .send({ status: 'answered' }); + + expect(response.status).toBe(200); + expect(mockPost.update).toHaveBeenCalledWith({ + status: 'answered', + closedBy: null, + closedAt: null, + }); + }); + + it('should reject invalid status', async () => { + const mockPost = { + id: 'post-1', + authorId: 'user-123', + isDeleted: false, + }; + + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .patch('/forum/posts/post-1/status') + .send({ status: 'invalid-status' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid status value'); + }); + }); + + describe('PUT /forum/comments/:id', () => { + it('should update own comment', async () => { + const mockComment = { + id: 'comment-1', + authorId: 'user-123', + postId: 'post-1', + content: 'Original', + isDeleted: false, + post: { id: 'post-1', isDeleted: false }, + update: jest.fn().mockResolvedValue(), + reload: jest.fn().mockResolvedValue(), + toJSON: function() { return this; }, + }; + + ForumComment.findByPk.mockResolvedValue(mockComment); + + const response = await request(app) + .put('/forum/comments/comment-1') + .send({ content: 'Updated content' }); + + expect(response.status).toBe(200); + expect(mockComment.update).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Updated content', + }) + ); + }); + + it('should return 403 when editing other users comment', async () => { + const mockComment = { + id: 'comment-1', + authorId: 'other-user', + isDeleted: false, + post: { id: 'post-1', isDeleted: false }, + }; + + ForumComment.findByPk.mockResolvedValue(mockComment); + + const response = await request(app) + .put('/forum/comments/comment-1') + .send({ content: 'Updated' }); + + expect(response.status).toBe(403); + }); + + it('should return 404 for non-existent comment', async () => { + ForumComment.findByPk.mockResolvedValue(null); + + const response = await request(app) + .put('/forum/comments/non-existent') + .send({ content: 'Updated' }); + + expect(response.status).toBe(404); + }); + }); + + describe('DELETE /forum/comments/:id', () => { + it('should soft delete own comment', async () => { + const mockComment = { + id: 'comment-1', + authorId: 'user-123', + postId: 'post-1', + isDeleted: false, + update: jest.fn().mockResolvedValue(), + }; + + const mockPost = { + id: 'post-1', + commentCount: 5, + decrement: jest.fn().mockResolvedValue(), + }; + + ForumComment.findByPk.mockResolvedValue(mockComment); + ForumPost.findByPk.mockResolvedValue(mockPost); + + const response = await request(app) + .delete('/forum/comments/comment-1'); + + // Returns 204 No Content on successful delete + expect(response.status).toBe(204); + expect(mockComment.update).toHaveBeenCalledWith({ isDeleted: true }); + expect(mockPost.decrement).toHaveBeenCalledWith('commentCount'); + }); + + it('should return 404 for non-existent comment', async () => { + ForumComment.findByPk.mockResolvedValue(null); + + const response = await request(app) + .delete('/forum/comments/non-existent'); + + expect(response.status).toBe(404); + }); + + it('should return 403 when deleting other users comment', async () => { + const mockComment = { + id: 'comment-1', + authorId: 'other-user', + isDeleted: false, + }; + + ForumComment.findByPk.mockResolvedValue(mockComment); + + const response = await request(app) + .delete('/forum/comments/comment-1'); + + expect(response.status).toBe(403); + }); + }); +}); diff --git a/backend/tests/unit/routes/items.test.js b/backend/tests/unit/routes/items.test.js index 4d6cd4d..aee60fa 100644 --- a/backend/tests/unit/routes/items.test.js +++ b/backend/tests/unit/routes/items.test.js @@ -199,7 +199,7 @@ describe('Items Routes', () => { { model: mockUserModel, as: 'owner', - attributes: ['id', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], limit: 20, @@ -580,7 +580,7 @@ describe('Items Routes', () => { { model: mockUserModel, as: 'renter', - attributes: ['id', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -648,7 +648,7 @@ describe('Items Routes', () => { { model: mockUserModel, as: 'owner', - attributes: ['id', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] }, { model: mockUserModel, diff --git a/backend/tests/unit/routes/rentals.test.js b/backend/tests/unit/routes/rentals.test.js index 5e23e52..39958a6 100644 --- a/backend/tests/unit/routes/rentals.test.js +++ b/backend/tests/unit/routes/rentals.test.js @@ -143,7 +143,7 @@ describe('Rentals Routes', () => { { model: User, as: 'owner', - attributes: ['id', 'firstName', 'lastName'], + attributes: ['id', 'firstName', 'lastName', 'imageFilename'], }, ], order: [['createdAt', 'DESC']], @@ -186,7 +186,7 @@ describe('Rentals Routes', () => { { model: User, as: 'renter', - attributes: ['id', 'firstName', 'lastName'], + attributes: ['id', 'firstName', 'lastName', 'imageFilename'], }, ], order: [['createdAt', 'DESC']], diff --git a/backend/tests/unit/services/UserService.test.js b/backend/tests/unit/services/UserService.test.js new file mode 100644 index 0000000..f7d2513 --- /dev/null +++ b/backend/tests/unit/services/UserService.test.js @@ -0,0 +1,352 @@ +const UserService = require('../../../services/UserService'); +const { User, UserAddress } = require('../../../models'); +const emailServices = require('../../../services/email'); +const logger = require('../../../utils/logger'); + +// Mock dependencies +jest.mock('../../../models', () => ({ + User: { + findByPk: jest.fn(), + }, + UserAddress: { + create: jest.fn(), + findOne: jest.fn(), + }, +})); + +jest.mock('../../../services/email', () => ({ + auth: { + sendPersonalInfoChangedEmail: jest.fn().mockResolvedValue(), + }, +})); + +jest.mock('../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +describe('UserService', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.NODE_ENV = 'test'; + }); + + describe('updateProfile', () => { + const mockUser = { + id: 'user-123', + email: 'original@example.com', + firstName: 'John', + lastName: 'Doe', + address1: '123 Main St', + address2: null, + city: 'New York', + state: 'NY', + zipCode: '10001', + country: 'USA', + update: jest.fn().mockResolvedValue(), + }; + + it('should update user profile successfully', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) // First call to find user + .mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' }); // Second call for return + + const updateData = { firstName: 'Jane' }; + const result = await UserService.updateProfile('user-123', updateData); + + expect(User.findByPk).toHaveBeenCalledWith('user-123'); + expect(mockUser.update).toHaveBeenCalledWith({ firstName: 'Jane' }, {}); + expect(result.firstName).toBe('Jane'); + }); + + it('should throw error when user not found', async () => { + User.findByPk.mockResolvedValue(null); + + await expect(UserService.updateProfile('non-existent', { firstName: 'Test' })) + .rejects.toThrow('User not found'); + }); + + it('should trim email and ignore empty email', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce(mockUser); + + await UserService.updateProfile('user-123', { email: ' new@example.com ' }); + + expect(mockUser.update).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + {} + ); + }); + + it('should not update email if empty string', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce(mockUser); + + await UserService.updateProfile('user-123', { email: ' ' }); + + // Email should not be in the update call + expect(mockUser.update).toHaveBeenCalledWith({}, {}); + }); + + it('should convert empty phone to null', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce(mockUser); + + await UserService.updateProfile('user-123', { phone: '' }); + + expect(mockUser.update).toHaveBeenCalledWith( + expect.objectContaining({ phone: null }), + {} + ); + }); + + it('should trim phone number', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce(mockUser); + + await UserService.updateProfile('user-123', { phone: ' 555-1234 ' }); + + expect(mockUser.update).toHaveBeenCalledWith( + expect.objectContaining({ phone: '555-1234' }), + {} + ); + }); + + it('should pass options to update call', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce(mockUser); + + const mockTransaction = { id: 'tx-123' }; + await UserService.updateProfile('user-123', { firstName: 'Jane' }, { transaction: mockTransaction }); + + expect(mockUser.update).toHaveBeenCalledWith( + expect.any(Object), + { transaction: mockTransaction } + ); + }); + + it('should not send email notification in test environment', async () => { + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' }); + + await UserService.updateProfile('user-123', { firstName: 'Jane' }); + + // Email should not be sent in test environment + expect(emailServices.auth.sendPersonalInfoChangedEmail).not.toHaveBeenCalled(); + }); + + it('should send email notification in production when personal info changes', async () => { + process.env.NODE_ENV = 'production'; + + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' }); + + await UserService.updateProfile('user-123', { firstName: 'Jane' }); + + expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser); + expect(logger.info).toHaveBeenCalledWith( + 'Personal information changed notification sent', + expect.objectContaining({ + userId: 'user-123', + changedFields: ['firstName'], + }) + ); + }); + + it('should handle email notification failure gracefully', async () => { + process.env.NODE_ENV = 'production'; + emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce( + new Error('Email service down') + ); + + User.findByPk + .mockResolvedValueOnce(mockUser) + .mockResolvedValueOnce({ ...mockUser, email: 'new@example.com' }); + + // Should not throw despite email failure + const result = await UserService.updateProfile('user-123', { email: 'new@example.com' }); + + expect(result).toBeDefined(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to send personal information changed notification', + expect.objectContaining({ + error: 'Email service down', + userId: 'user-123', + }) + ); + }); + }); + + describe('createUserAddress', () => { + const mockUser = { + id: 'user-123', + email: 'user@example.com', + }; + + const addressData = { + label: 'Home', + address1: '456 Oak Ave', + city: 'Boston', + state: 'MA', + zipCode: '02101', + country: 'USA', + }; + + it('should create a new address successfully', async () => { + const mockAddress = { id: 'addr-123', ...addressData, userId: 'user-123' }; + + User.findByPk.mockResolvedValue(mockUser); + UserAddress.create.mockResolvedValue(mockAddress); + + const result = await UserService.createUserAddress('user-123', addressData); + + expect(User.findByPk).toHaveBeenCalledWith('user-123'); + expect(UserAddress.create).toHaveBeenCalledWith({ + ...addressData, + userId: 'user-123', + }); + expect(result.id).toBe('addr-123'); + }); + + it('should throw error when user not found', async () => { + User.findByPk.mockResolvedValue(null); + + await expect(UserService.createUserAddress('non-existent', addressData)) + .rejects.toThrow('User not found'); + }); + + it('should send notification in production', async () => { + process.env.NODE_ENV = 'production'; + const mockAddress = { id: 'addr-123', ...addressData }; + + User.findByPk.mockResolvedValue(mockUser); + UserAddress.create.mockResolvedValue(mockAddress); + + await UserService.createUserAddress('user-123', addressData); + + expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser); + }); + }); + + describe('updateUserAddress', () => { + const mockAddress = { + id: 'addr-123', + userId: 'user-123', + address1: '123 Old St', + update: jest.fn().mockResolvedValue(), + }; + + it('should update address successfully', async () => { + UserAddress.findOne.mockResolvedValue(mockAddress); + User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' }); + + const result = await UserService.updateUserAddress('user-123', 'addr-123', { + address1: '789 New St', + }); + + expect(UserAddress.findOne).toHaveBeenCalledWith({ + where: { id: 'addr-123', userId: 'user-123' }, + }); + expect(mockAddress.update).toHaveBeenCalledWith({ address1: '789 New St' }); + expect(result.id).toBe('addr-123'); + }); + + it('should throw error when address not found', async () => { + UserAddress.findOne.mockResolvedValue(null); + + await expect( + UserService.updateUserAddress('user-123', 'non-existent', { address1: 'New' }) + ).rejects.toThrow('Address not found'); + }); + + it('should send notification in production', async () => { + process.env.NODE_ENV = 'production'; + const mockUser = { id: 'user-123', email: 'user@example.com' }; + + UserAddress.findOne.mockResolvedValue(mockAddress); + User.findByPk.mockResolvedValue(mockUser); + + await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' }); + + expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser); + }); + + it('should handle email failure gracefully', async () => { + process.env.NODE_ENV = 'production'; + emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce( + new Error('Email failed') + ); + + UserAddress.findOne.mockResolvedValue(mockAddress); + User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' }); + + const result = await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' }); + + expect(result).toBeDefined(); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('deleteUserAddress', () => { + const mockAddress = { + id: 'addr-123', + userId: 'user-123', + destroy: jest.fn().mockResolvedValue(), + }; + + it('should delete address successfully', async () => { + UserAddress.findOne.mockResolvedValue(mockAddress); + User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' }); + + await UserService.deleteUserAddress('user-123', 'addr-123'); + + expect(UserAddress.findOne).toHaveBeenCalledWith({ + where: { id: 'addr-123', userId: 'user-123' }, + }); + expect(mockAddress.destroy).toHaveBeenCalled(); + }); + + it('should throw error when address not found', async () => { + UserAddress.findOne.mockResolvedValue(null); + + await expect( + UserService.deleteUserAddress('user-123', 'non-existent') + ).rejects.toThrow('Address not found'); + }); + + it('should send notification in production', async () => { + process.env.NODE_ENV = 'production'; + const mockUser = { id: 'user-123', email: 'user@example.com' }; + + UserAddress.findOne.mockResolvedValue(mockAddress); + User.findByPk.mockResolvedValue(mockUser); + + await UserService.deleteUserAddress('user-123', 'addr-123'); + + expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser); + }); + + it('should handle email failure gracefully', async () => { + process.env.NODE_ENV = 'production'; + emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce( + new Error('Email failed') + ); + + UserAddress.findOne.mockResolvedValue(mockAddress); + User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' }); + + // Should not throw + await UserService.deleteUserAddress('user-123', 'addr-123'); + + expect(logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/__tests__/components/AuthModal.test.tsx b/frontend/src/__tests__/components/AuthModal.test.tsx new file mode 100644 index 0000000..9bf78ac --- /dev/null +++ b/frontend/src/__tests__/components/AuthModal.test.tsx @@ -0,0 +1,425 @@ +/** + * AuthModal Component Tests + * + * Tests for the AuthModal component including login, signup, + * form validation, and modal behavior. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AuthModal from '../../components/AuthModal'; + +// Mock the auth context +const mockLogin = jest.fn(); +const mockRegister = jest.fn(); + +jest.mock('../../contexts/AuthContext', () => ({ + ...jest.requireActual('../../contexts/AuthContext'), + useAuth: () => ({ + login: mockLogin, + register: mockRegister, + user: null, + loading: false, + }), +})); + +// Mock child components +jest.mock('../../components/PasswordStrengthMeter', () => { + return function MockPasswordStrengthMeter({ password }: { password: string }) { + return
Strength: {password.length > 8 ? 'Strong' : 'Weak'}
; + }; +}); + +jest.mock('../../components/PasswordInput', () => { + return function MockPasswordInput({ + id, + label, + value, + onChange, + required + }: { + id: string; + label: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + required?: boolean; + }) { + return ( +
+ + +
+ ); + }; +}); + +jest.mock('../../components/ForgotPasswordModal', () => { + return function MockForgotPasswordModal({ + show, + onHide, + onBackToLogin + }: { + show: boolean; + onHide: () => void; + onBackToLogin: () => void; + }) { + if (!show) return null; + return ( +
+ + +
+ ); + }; +}); + +jest.mock('../../components/VerificationCodeModal', () => { + return function MockVerificationCodeModal({ + show, + onHide, + email, + onVerified + }: { + show: boolean; + onHide: () => void; + email: string; + onVerified: () => void; + }) { + if (!show) return null; + return ( +
+

Verify email: {email}

+ + +
+ ); + }; +}); + +describe('AuthModal', () => { + const defaultProps = { + show: true, + onHide: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Helper to get email input (it's a textbox with type email) + const getEmailInput = () => screen.getByRole('textbox', { hidden: false }); + + // Helper to get inputs by their preceding label text + const getInputByLabelText = (container: HTMLElement, labelText: string) => { + const label = Array.from(container.querySelectorAll('label')).find( + l => l.textContent === labelText + ); + if (!label) throw new Error(`Label "${labelText}" not found`); + // Get the next sibling input or the input inside the same parent + const parent = label.parentElement; + return parent?.querySelector('input') as HTMLInputElement; + }; + + describe('Rendering', () => { + it('should render login form by default', () => { + const { container } = render(); + + expect(screen.getByText('Welcome to CommunityRentals.App')).toBeInTheDocument(); + expect(getInputByLabelText(container, 'Email')).toBeInTheDocument(); + expect(screen.getByTestId('password-input')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument(); + }); + + it('should render signup form when initialMode is signup', () => { + const { container } = render(); + + expect(getInputByLabelText(container, 'First Name')).toBeInTheDocument(); + expect(getInputByLabelText(container, 'Last Name')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument(); + expect(screen.getByTestId('password-strength-meter')).toBeInTheDocument(); + }); + + it('should not render when show is false', () => { + render(); + + expect(screen.queryByText('Welcome to CommunityRentals.App')).not.toBeInTheDocument(); + }); + + it('should render Google login button', () => { + render(); + + expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument(); + }); + + it('should render forgot password link in login mode', () => { + render(); + + expect(screen.getByText('Forgot password?')).toBeInTheDocument(); + }); + + it('should not render forgot password link in signup mode', () => { + render(); + + expect(screen.queryByText('Forgot password?')).not.toBeInTheDocument(); + }); + }); + + describe('Mode Switching', () => { + it('should switch from login to signup mode', async () => { + const { container } = render(); + + // Initially in login mode + expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument(); + + // Click "Sign up" link + fireEvent.click(screen.getByText('Sign up')); + + // Should now be in signup mode + expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument(); + expect(getInputByLabelText(container, 'First Name')).toBeInTheDocument(); + }); + + it('should switch from signup to login mode', async () => { + render(); + + // Initially in signup mode + expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument(); + + // Click "Log in" link + fireEvent.click(screen.getByText('Log in')); + + // Should now be in login mode + expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument(); + expect(screen.queryByText('First Name')).not.toBeInTheDocument(); + }); + }); + + describe('Login Form Submission', () => { + it('should call login with email and password', async () => { + mockLogin.mockResolvedValue({}); + const { container } = render(); + + // Fill in the form + const emailInput = getInputByLabelText(container, 'Email'); + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'password123'); + + // Submit the form + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123'); + }); + }); + + it('should call onHide after successful login', async () => { + mockLogin.mockResolvedValue({}); + const { container } = render(); + + const emailInput = getInputByLabelText(container, 'Email'); + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'password123'); + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + await waitFor(() => { + expect(defaultProps.onHide).toHaveBeenCalled(); + }); + }); + + it('should display error message on login failure', async () => { + mockLogin.mockRejectedValue({ + response: { data: { error: 'Invalid credentials' } }, + }); + + const { container } = render(); + + const emailInput = getInputByLabelText(container, 'Email'); + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'wrongpassword'); + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + await waitFor(() => { + expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); + }); + }); + + it('should show loading state during login', async () => { + // Make login take some time + mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))); + + const { container } = render(); + + const emailInput = getInputByLabelText(container, 'Email'); + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'password123'); + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument(); + }); + }); + + describe('Signup Form Submission', () => { + it('should call register with user data', async () => { + mockRegister.mockResolvedValue({}); + const { container } = render(); + + await userEvent.type(getInputByLabelText(container, 'First Name'), 'John'); + await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe'); + await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!'); + + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith({ + email: 'john@example.com', + password: 'StrongPass123!', + firstName: 'John', + lastName: 'Doe', + username: 'john', // Generated from email + }); + }); + }); + + it('should show verification modal after successful signup', async () => { + mockRegister.mockResolvedValue({}); + const { container } = render(); + + await userEvent.type(getInputByLabelText(container, 'First Name'), 'John'); + await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe'); + await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!'); + + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })); + + await waitFor(() => { + expect(screen.getByTestId('verification-modal')).toBeInTheDocument(); + expect(screen.getByText('Verify email: john@example.com')).toBeInTheDocument(); + }); + }); + + it('should display error message on signup failure', async () => { + mockRegister.mockRejectedValue({ + response: { data: { error: 'Email already exists' } }, + }); + + const { container } = render(); + + await userEvent.type(getInputByLabelText(container, 'First Name'), 'John'); + await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe'); + await userEvent.type(getInputByLabelText(container, 'Email'), 'existing@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!'); + + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })); + + await waitFor(() => { + expect(screen.getByText('Email already exists')).toBeInTheDocument(); + }); + }); + }); + + describe('Forgot Password', () => { + it('should show forgot password modal when link is clicked', async () => { + render(); + + fireEvent.click(screen.getByText('Forgot password?')); + + expect(screen.getByTestId('forgot-password-modal')).toBeInTheDocument(); + }); + + it('should hide forgot password modal and show login when back is clicked', async () => { + render(); + + // Open forgot password modal + fireEvent.click(screen.getByText('Forgot password?')); + expect(screen.getByTestId('forgot-password-modal')).toBeInTheDocument(); + + // Click back to login + fireEvent.click(screen.getByTestId('back-to-login')); + + // Should show login form again + await waitFor(() => { + expect(screen.queryByTestId('forgot-password-modal')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument(); + }); + }); + }); + + describe('Modal Close', () => { + it('should call onHide when close button is clicked', async () => { + render(); + + // Click close button (btn-close class) + const closeButton = document.querySelector('.btn-close') as HTMLButtonElement; + fireEvent.click(closeButton); + + expect(defaultProps.onHide).toHaveBeenCalled(); + }); + }); + + describe('Google OAuth', () => { + it('should redirect to Google OAuth when Google button is clicked', () => { + // Mock window.location + const originalLocation = window.location; + delete (window as any).location; + window.location = { ...originalLocation, href: '' } as Location; + + render(); + + fireEvent.click(screen.getByRole('button', { name: /continue with google/i })); + + // Check that window.location.href was set to Google OAuth URL + expect(window.location.href).toContain('accounts.google.com'); + + // Restore + window.location = originalLocation; + }); + }); + + describe('Accessibility', () => { + it('should have password label associated with input', () => { + render(); + + // Password input has proper htmlFor through the mock + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + }); + + it('should display error in an alert role', async () => { + mockLogin.mockRejectedValue({ + response: { data: { error: 'Test error' } }, + }); + + const { container } = render(); + + const emailInput = getInputByLabelText(container, 'Email'); + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.type(screen.getByTestId('password-input'), 'password'); + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + }); + + describe('Terms and Privacy Links', () => { + it('should display terms and privacy links', () => { + render(); + + expect(screen.getByText('Terms of Service')).toHaveAttribute('href', '/terms'); + expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', '/privacy'); + }); + }); +}); diff --git a/frontend/src/__tests__/components/Navbar.test.tsx b/frontend/src/__tests__/components/Navbar.test.tsx new file mode 100644 index 0000000..2898cea --- /dev/null +++ b/frontend/src/__tests__/components/Navbar.test.tsx @@ -0,0 +1,268 @@ +/** + * Navbar Component Tests + * + * Tests for the Navbar component including navigation links, + * user authentication state, search functionality, and notifications. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import Navbar from '../../components/Navbar'; +import { rentalAPI, messageAPI } from '../../services/api'; + +// Mock dependencies +jest.mock('../../services/api', () => ({ + rentalAPI: { + getPendingRequestsCount: jest.fn(), + }, + messageAPI: { + getUnreadCount: jest.fn(), + }, +})); + +// Mock socket context +jest.mock('../../contexts/SocketContext', () => ({ + useSocket: () => ({ + onNewMessage: jest.fn(() => () => {}), + onMessageRead: jest.fn(() => () => {}), + }), +})); + +// Variable to control auth state per test +let mockUser: any = null; +const mockLogout = jest.fn(); +const mockOpenAuthModal = jest.fn(); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuth: () => ({ + user: mockUser, + logout: mockLogout, + openAuthModal: mockOpenAuthModal, + }), +})); + +// Mock useNavigate +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +// Helper to render with Router +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('Navbar', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUser = null; + + // Default mock implementations + (rentalAPI.getPendingRequestsCount as jest.Mock).mockResolvedValue({ data: { count: 0 } }); + (messageAPI.getUnreadCount as jest.Mock).mockResolvedValue({ data: { count: 0 } }); + }); + + describe('Branding', () => { + it('should display the brand name', () => { + renderWithRouter(); + + expect(screen.getByText('CommunityRentals.App')).toBeInTheDocument(); + }); + + it('should link brand to home page', () => { + renderWithRouter(); + + const brandLink = screen.getByRole('link', { name: /CommunityRentals.App/i }); + expect(brandLink).toHaveAttribute('href', '/'); + }); + }); + + describe('Search Functionality', () => { + it('should render search input', () => { + renderWithRouter(); + + expect(screen.getByPlaceholderText('Search items...')).toBeInTheDocument(); + }); + + it('should render location input', () => { + renderWithRouter(); + + expect(screen.getByPlaceholderText('City or ZIP')).toBeInTheDocument(); + }); + + it('should render search button', () => { + renderWithRouter(); + + // Search button has an icon + const searchButton = document.querySelector('.bi-search'); + expect(searchButton).toBeInTheDocument(); + }); + + it('should navigate to items page when search is submitted', () => { + renderWithRouter(); + + const searchInput = screen.getByPlaceholderText('Search items...'); + fireEvent.change(searchInput, { target: { value: 'camera' } }); + + const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement; + fireEvent.click(searchButton); + + expect(mockNavigate).toHaveBeenCalledWith('/items?search=camera'); + }); + + it('should handle Enter key in search input', () => { + renderWithRouter(); + + const searchInput = screen.getByPlaceholderText('Search items...'); + fireEvent.change(searchInput, { target: { value: 'tent' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockNavigate).toHaveBeenCalledWith('/items?search=tent'); + }); + + it('should append city to search URL', () => { + renderWithRouter(); + + const searchInput = screen.getByPlaceholderText('Search items...'); + const locationInput = screen.getByPlaceholderText('City or ZIP'); + + fireEvent.change(searchInput, { target: { value: 'kayak' } }); + fireEvent.change(locationInput, { target: { value: 'Seattle' } }); + + const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement; + fireEvent.click(searchButton); + + expect(mockNavigate).toHaveBeenCalledWith('/items?search=kayak&city=Seattle'); + }); + + it('should append zipCode when location is a zip code', () => { + renderWithRouter(); + + const searchInput = screen.getByPlaceholderText('Search items...'); + const locationInput = screen.getByPlaceholderText('City or ZIP'); + + fireEvent.change(searchInput, { target: { value: 'bike' } }); + fireEvent.change(locationInput, { target: { value: '98101' } }); + + const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement; + fireEvent.click(searchButton); + + expect(mockNavigate).toHaveBeenCalledWith('/items?search=bike&zipCode=98101'); + }); + + it('should clear search fields after search', () => { + renderWithRouter(); + + const searchInput = screen.getByPlaceholderText('Search items...') as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: 'camera' } }); + + const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement; + fireEvent.click(searchButton); + + expect(searchInput.value).toBe(''); + }); + }); + + describe('Logged Out State', () => { + it('should show login button when user is not logged in', () => { + renderWithRouter(); + + expect(screen.getByRole('button', { name: 'Login or Sign Up' })).toBeInTheDocument(); + }); + + it('should call openAuthModal when login button is clicked', () => { + renderWithRouter(); + + fireEvent.click(screen.getByRole('button', { name: 'Login or Sign Up' })); + + expect(mockOpenAuthModal).toHaveBeenCalledWith('login'); + }); + }); + + describe('Logged In State', () => { + beforeEach(() => { + mockUser = { + id: 'user-123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }; + }); + + it('should show user name when logged in', () => { + renderWithRouter(); + + expect(screen.getByText('John')).toBeInTheDocument(); + }); + + it('should not show login button when logged in', () => { + renderWithRouter(); + + expect(screen.queryByRole('button', { name: 'Login or Sign Up' })).not.toBeInTheDocument(); + }); + + it('should show profile link in dropdown', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: /Profile/i })).toHaveAttribute('href', '/profile'); + }); + + it('should show renting link in dropdown', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: /Renting/i })).toHaveAttribute('href', '/renting'); + }); + + it('should show owning link in dropdown', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: /Owning/i })).toHaveAttribute('href', '/owning'); + }); + + it('should show messages link in dropdown', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: /Messages/i })).toHaveAttribute('href', '/messages'); + }); + + it('should show forum link in dropdown', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: /Forum/i })).toHaveAttribute('href', '/forum'); + }); + + it('should show earnings link in dropdown', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: /Earnings/i })).toHaveAttribute('href', '/earnings'); + }); + + it('should call logout and navigate home when logout is clicked', () => { + renderWithRouter(); + + const logoutButton = screen.getByRole('button', { name: /Logout/i }); + fireEvent.click(logoutButton); + + expect(mockLogout).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + describe('Start Earning Link', () => { + it('should show Start Earning link', () => { + renderWithRouter(); + + expect(screen.getByRole('link', { name: 'Start Earning' })).toHaveAttribute('href', '/create-item'); + }); + }); + + describe('Mobile Navigation', () => { + it('should render mobile toggle button', () => { + renderWithRouter(); + + expect(screen.getByLabelText('Toggle navigation')).toBeInTheDocument(); + }); + }); +});