updating unit and integration tests

This commit is contained in:
jackiettran
2025-12-20 14:59:09 -05:00
parent 4e0a4ef019
commit bd1bd5014c
14 changed files with 2424 additions and 100 deletions

View File

@@ -1,5 +1,22 @@
module.exports = {
projects: [
{
displayName: 'unit',
testEnvironment: 'node',
testMatch: ['**/tests/unit/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 10000,
},
{
displayName: 'integration',
testEnvironment: 'node',
testMatch: ['**/tests/integration/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/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: ['<rootDir>/tests/setup.js'],
forceExit: true,
testTimeout: 10000,
coverageThreshold: {
global: {
lines: 80,

View File

@@ -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",

View File

@@ -553,7 +553,7 @@ router.post(
}
// Validate the code
if (!user.isVerificationTokenValid(input)) {
if (!user.isVerificationTokenValid(code)) {
// Increment failed attempts
await user.incrementVerificationAttempts();

View File

@@ -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';

View File

@@ -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');
});
});

View File

@@ -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);
});

View File

@@ -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 = {

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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,

View File

@@ -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']],

View File

@@ -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();
});
});
});

View File

@@ -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 <div data-testid="password-strength-meter">Strength: {password.length > 8 ? 'Strong' : 'Weak'}</div>;
};
});
jest.mock('../../components/PasswordInput', () => {
return function MockPasswordInput({
id,
label,
value,
onChange,
required
}: {
id: string;
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
}) {
return (
<div className="mb-3">
<label htmlFor={id} className="form-label">{label}</label>
<input
id={id}
type="password"
className="form-control"
value={value}
onChange={onChange}
required={required}
data-testid="password-input"
/>
</div>
);
};
});
jest.mock('../../components/ForgotPasswordModal', () => {
return function MockForgotPasswordModal({
show,
onHide,
onBackToLogin
}: {
show: boolean;
onHide: () => void;
onBackToLogin: () => void;
}) {
if (!show) return null;
return (
<div data-testid="forgot-password-modal">
<button onClick={onBackToLogin} data-testid="back-to-login">Back to Login</button>
<button onClick={onHide}>Close</button>
</div>
);
};
});
jest.mock('../../components/VerificationCodeModal', () => {
return function MockVerificationCodeModal({
show,
onHide,
email,
onVerified
}: {
show: boolean;
onHide: () => void;
email: string;
onVerified: () => void;
}) {
if (!show) return null;
return (
<div data-testid="verification-modal">
<p>Verify email: {email}</p>
<button onClick={onVerified} data-testid="verify-button">Verify</button>
<button onClick={onHide}>Close</button>
</div>
);
};
});
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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} initialMode="signup" />);
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(<AuthModal {...defaultProps} show={false} />);
expect(screen.queryByText('Welcome to CommunityRentals.App')).not.toBeInTheDocument();
});
it('should render Google login button', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
});
it('should render forgot password link in login mode', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
});
it('should not render forgot password link in signup mode', () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
expect(screen.queryByText('Forgot password?')).not.toBeInTheDocument();
});
});
describe('Mode Switching', () => {
it('should switch from login to signup mode', async () => {
const { container } = render(<AuthModal {...defaultProps} />);
// 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(<AuthModal {...defaultProps} initialMode="signup" />);
// 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(<AuthModal {...defaultProps} />);
// 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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} initialMode="signup" />);
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(<AuthModal {...defaultProps} initialMode="signup" />);
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(<AuthModal {...defaultProps} initialMode="signup" />);
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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} />);
// 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(<AuthModal {...defaultProps} />);
// 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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} initialMode="signup" />);
// 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(<AuthModal {...defaultProps} />);
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(<AuthModal {...defaultProps} />);
expect(screen.getByText('Terms of Service')).toHaveAttribute('href', '/terms');
expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', '/privacy');
});
});
});

View File

@@ -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(<BrowserRouter>{component}</BrowserRouter>);
};
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(<Navbar />);
expect(screen.getByText('CommunityRentals.App')).toBeInTheDocument();
});
it('should link brand to home page', () => {
renderWithRouter(<Navbar />);
const brandLink = screen.getByRole('link', { name: /CommunityRentals.App/i });
expect(brandLink).toHaveAttribute('href', '/');
});
});
describe('Search Functionality', () => {
it('should render search input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('Search items...')).toBeInTheDocument();
});
it('should render location input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('City or ZIP')).toBeInTheDocument();
});
it('should render search button', () => {
renderWithRouter(<Navbar />);
// 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(<Navbar />);
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(<Navbar />);
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(<Navbar />);
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(<Navbar />);
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(<Navbar />);
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(<Navbar />);
expect(screen.getByRole('button', { name: 'Login or Sign Up' })).toBeInTheDocument();
});
it('should call openAuthModal when login button is clicked', () => {
renderWithRouter(<Navbar />);
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(<Navbar />);
expect(screen.getByText('John')).toBeInTheDocument();
});
it('should not show login button when logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.queryByRole('button', { name: 'Login or Sign Up' })).not.toBeInTheDocument();
});
it('should show profile link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Profile/i })).toHaveAttribute('href', '/profile');
});
it('should show renting link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Renting/i })).toHaveAttribute('href', '/renting');
});
it('should show owning link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Owning/i })).toHaveAttribute('href', '/owning');
});
it('should show messages link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Messages/i })).toHaveAttribute('href', '/messages');
});
it('should show forum link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Forum/i })).toHaveAttribute('href', '/forum');
});
it('should show earnings link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Earnings/i })).toHaveAttribute('href', '/earnings');
});
it('should call logout and navigate home when logout is clicked', () => {
renderWithRouter(<Navbar />);
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(<Navbar />);
expect(screen.getByRole('link', { name: 'Start Earning' })).toHaveAttribute('href', '/create-item');
});
});
describe('Mobile Navigation', () => {
it('should render mobile toggle button', () => {
renderWithRouter(<Navbar />);
expect(screen.getByLabelText('Toggle navigation')).toBeInTheDocument();
});
});
});