updating unit and integration tests
This commit is contained in:
@@ -1,5 +1,22 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -553,7 +553,7 @@ router.post(
|
||||
}
|
||||
|
||||
// Validate the code
|
||||
if (!user.isVerificationTokenValid(input)) {
|
||||
if (!user.isVerificationTokenValid(code)) {
|
||||
// Increment failed attempts
|
||||
await user.incrementVerificationAttempts();
|
||||
|
||||
|
||||
13
backend/tests/integration-setup.js
Normal file
13
backend/tests/integration-setup.js
Normal 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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
328
backend/tests/unit/routes/conditionChecks.test.js
Normal file
328
backend/tests/unit/routes/conditionChecks.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
813
backend/tests/unit/routes/forum.test.js
Normal file
813
backend/tests/unit/routes/forum.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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']],
|
||||
|
||||
352
backend/tests/unit/services/UserService.test.js
Normal file
352
backend/tests/unit/services/UserService.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
425
frontend/src/__tests__/components/AuthModal.test.tsx
Normal file
425
frontend/src/__tests__/components/AuthModal.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
268
frontend/src/__tests__/components/Navbar.test.tsx
Normal file
268
frontend/src/__tests__/components/Navbar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user