updating unit and integration tests
This commit is contained in:
@@ -1,5 +1,22 @@
|
|||||||
module.exports = {
|
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',
|
coverageDirectory: 'coverage',
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'**/*.js',
|
'**/*.js',
|
||||||
@@ -9,10 +26,6 @@ module.exports = {
|
|||||||
'!jest.config.js'
|
'!jest.config.js'
|
||||||
],
|
],
|
||||||
coverageReporters: ['text', 'lcov', 'html'],
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
testMatch: ['**/tests/**/*.test.js'],
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
|
||||||
forceExit: true,
|
|
||||||
testTimeout: 10000,
|
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
lines: 80,
|
lines: 80,
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
|
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
|
||||||
"test": "NODE_ENV=test jest",
|
"test": "NODE_ENV=test jest",
|
||||||
"test:watch": "NODE_ENV=test jest --watch",
|
"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:unit": "NODE_ENV=test jest tests/unit",
|
||||||
"test:integration": "NODE_ENV=test jest tests/integration",
|
"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": "sequelize-cli db:migrate",
|
||||||
"db:migrate:undo": "sequelize-cli db:migrate:undo",
|
"db:migrate:undo": "sequelize-cli db:migrate:undo",
|
||||||
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the code
|
// Validate the code
|
||||||
if (!user.isVerificationTokenValid(input)) {
|
if (!user.isVerificationTokenValid(code)) {
|
||||||
// Increment failed attempts
|
// Increment failed attempts
|
||||||
await user.incrementVerificationAttempts();
|
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(),
|
passwordResetRequestLimiter: (req, res, next) => next(),
|
||||||
verifyEmailLimiter: (req, res, next) => next(),
|
verifyEmailLimiter: (req, res, next) => next(),
|
||||||
resendVerificationLimiter: (req, res, next) => next(),
|
resendVerificationLimiter: (req, res, next) => next(),
|
||||||
|
emailVerificationLimiter: (req, res, next) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock CSRF protection for tests
|
// Mock CSRF protection for tests
|
||||||
@@ -225,7 +226,7 @@ describe('Auth Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
.expect(401);
|
.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 () => {
|
it('should reject login with non-existent email', async () => {
|
||||||
@@ -237,7 +238,7 @@ describe('Auth Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
.expect(401);
|
.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 () => {
|
it('should increment login attempts on failed login', async () => {
|
||||||
@@ -421,7 +422,8 @@ describe('Auth Integration Tests', () => {
|
|||||||
|
|
||||||
describe('POST /auth/verify-email', () => {
|
describe('POST /auth/verify-email', () => {
|
||||||
let testUser;
|
let testUser;
|
||||||
let verificationToken;
|
let verificationCode;
|
||||||
|
let accessToken;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testUser = await createTestUser({
|
testUser = await createTestUser({
|
||||||
@@ -430,13 +432,21 @@ describe('Auth Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
await testUser.generateVerificationToken();
|
await testUser.generateVerificationToken();
|
||||||
await testUser.reload();
|
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)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: verificationToken })
|
.set('Cookie', `accessToken=${accessToken}`)
|
||||||
|
.send({ code: verificationCode })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.message).toBe('Email verified successfully');
|
expect(response.body.message).toBe('Email verified successfully');
|
||||||
@@ -448,13 +458,14 @@ describe('Auth Integration Tests', () => {
|
|||||||
expect(testUser.verificationToken).toBeNull();
|
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)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: 'invalid-token' })
|
.set('Cookie', `accessToken=${accessToken}`)
|
||||||
|
.send({ code: '000000' })
|
||||||
.expect(400);
|
.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 () => {
|
it('should reject verification for already verified user', async () => {
|
||||||
@@ -463,10 +474,11 @@ describe('Auth Integration Tests', () => {
|
|||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: verificationToken })
|
.set('Cookie', `accessToken=${accessToken}`)
|
||||||
|
.send({ code: verificationCode })
|
||||||
.expect(400);
|
.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',
|
lastName: 'User',
|
||||||
verificationToken: null,
|
verificationToken: null,
|
||||||
verificationTokenExpiry: null,
|
verificationTokenExpiry: null,
|
||||||
|
verificationAttempts: 0,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
verifiedAt: null,
|
verifiedAt: null,
|
||||||
update: jest.fn().mockImplementation(function(updates) {
|
update: jest.fn().mockImplementation(function(updates) {
|
||||||
@@ -53,18 +54,17 @@ describe('User Model - Email Verification', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('generateVerificationToken', () => {
|
describe('generateVerificationToken', () => {
|
||||||
it('should generate a random token and set 24-hour expiry', async () => {
|
it('should generate a 6-digit code and set 24-hour expiry', async () => {
|
||||||
const mockRandomBytes = Buffer.from('a'.repeat(32));
|
const mockCode = 123456;
|
||||||
const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times
|
crypto.randomInt.mockReturnValue(mockCode);
|
||||||
|
|
||||||
crypto.randomBytes.mockReturnValue(mockRandomBytes);
|
|
||||||
|
|
||||||
await User.prototype.generateVerificationToken.call(mockUser);
|
await User.prototype.generateVerificationToken.call(mockUser);
|
||||||
|
|
||||||
expect(crypto.randomBytes).toHaveBeenCalledWith(32);
|
expect(crypto.randomInt).toHaveBeenCalledWith(100000, 999999);
|
||||||
expect(mockUser.update).toHaveBeenCalledWith(
|
expect(mockUser.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
verificationToken: mockToken
|
verificationToken: '123456',
|
||||||
|
verificationAttempts: 0,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,40 +77,40 @@ describe('User Model - Email Verification', () => {
|
|||||||
expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
|
expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update the user with token and expiry', async () => {
|
it('should update the user with code and expiry', async () => {
|
||||||
const mockRandomBytes = Buffer.from('b'.repeat(32));
|
const mockCode = 654321;
|
||||||
const mockToken = mockRandomBytes.toString('hex');
|
crypto.randomInt.mockReturnValue(mockCode);
|
||||||
|
|
||||||
crypto.randomBytes.mockReturnValue(mockRandomBytes);
|
|
||||||
|
|
||||||
const result = await User.prototype.generateVerificationToken.call(mockUser);
|
const result = await User.prototype.generateVerificationToken.call(mockUser);
|
||||||
|
|
||||||
expect(mockUser.update).toHaveBeenCalledTimes(1);
|
expect(mockUser.update).toHaveBeenCalledTimes(1);
|
||||||
expect(result.verificationToken).toBe(mockToken);
|
expect(result.verificationToken).toBe('654321');
|
||||||
expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
|
expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate unique tokens on multiple calls', async () => {
|
it('should generate unique codes on multiple calls', async () => {
|
||||||
const mockRandomBytes1 = Buffer.from('a'.repeat(32));
|
crypto.randomInt
|
||||||
const mockRandomBytes2 = Buffer.from('b'.repeat(32));
|
.mockReturnValueOnce(111111)
|
||||||
|
.mockReturnValueOnce(222222);
|
||||||
crypto.randomBytes
|
|
||||||
.mockReturnValueOnce(mockRandomBytes1)
|
|
||||||
.mockReturnValueOnce(mockRandomBytes2);
|
|
||||||
|
|
||||||
await User.prototype.generateVerificationToken.call(mockUser);
|
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);
|
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', () => {
|
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', () => {
|
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
|
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||||
|
|
||||||
mockUser.verificationToken = validToken;
|
mockUser.verificationToken = validToken;
|
||||||
@@ -131,25 +131,25 @@ describe('User Model - Email Verification', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for missing expiry', () => {
|
it('should return false for missing expiry', () => {
|
||||||
mockUser.verificationToken = 'valid-token';
|
mockUser.verificationToken = '123456';
|
||||||
mockUser.verificationTokenExpiry = null;
|
mockUser.verificationTokenExpiry = null;
|
||||||
|
|
||||||
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token');
|
const result = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for mismatched token', () => {
|
it('should return false for mismatched token', () => {
|
||||||
mockUser.verificationToken = 'correct-token';
|
mockUser.verificationToken = '123456';
|
||||||
mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
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);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for expired token', () => {
|
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
|
const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
|
||||||
|
|
||||||
mockUser.verificationToken = validToken;
|
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', () => {
|
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
|
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
|
||||||
|
|
||||||
mockUser.verificationToken = validToken;
|
mockUser.verificationToken = validToken;
|
||||||
@@ -173,7 +173,7 @@ describe('User Model - Email Verification', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle edge case of token expiring exactly now', () => {
|
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
|
// Set expiry 1ms in the future to handle timing precision
|
||||||
const nowExpiry = new Date(Date.now() + 1);
|
const nowExpiry = new Date(Date.now() + 1);
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ describe('User Model - Email Verification', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle string dates correctly', () => {
|
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
|
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date
|
||||||
|
|
||||||
mockUser.verificationToken = validToken;
|
mockUser.verificationToken = validToken;
|
||||||
@@ -201,7 +201,7 @@ describe('User Model - Email Verification', () => {
|
|||||||
|
|
||||||
describe('verifyEmail', () => {
|
describe('verifyEmail', () => {
|
||||||
it('should mark user as verified and clear token fields', async () => {
|
it('should mark user as verified and clear token fields', async () => {
|
||||||
mockUser.verificationToken = 'some-token';
|
mockUser.verificationToken = '123456';
|
||||||
mockUser.verificationTokenExpiry = new Date();
|
mockUser.verificationTokenExpiry = new Date();
|
||||||
|
|
||||||
await User.prototype.verifyEmail.call(mockUser);
|
await User.prototype.verifyEmail.call(mockUser);
|
||||||
@@ -245,19 +245,22 @@ describe('User Model - Email Verification', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Complete verification flow', () => {
|
describe('Complete verification flow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
|
||||||
|
});
|
||||||
|
|
||||||
it('should complete full verification flow successfully', async () => {
|
it('should complete full verification flow successfully', async () => {
|
||||||
// Step 1: Generate verification token
|
// Step 1: Generate verification code
|
||||||
const mockRandomBytes = Buffer.from('c'.repeat(32));
|
const mockCode = 999888;
|
||||||
const mockToken = mockRandomBytes.toString('hex');
|
crypto.randomInt.mockReturnValue(mockCode);
|
||||||
crypto.randomBytes.mockReturnValue(mockRandomBytes);
|
|
||||||
|
|
||||||
await User.prototype.generateVerificationToken.call(mockUser);
|
await User.prototype.generateVerificationToken.call(mockUser);
|
||||||
|
|
||||||
expect(mockUser.verificationToken).toBe(mockToken);
|
expect(mockUser.verificationToken).toBe('999888');
|
||||||
expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
|
expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
|
||||||
|
|
||||||
// Step 2: Validate token
|
// Step 2: Validate code
|
||||||
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken);
|
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '999888');
|
||||||
expect(isValid).toBe(true);
|
expect(isValid).toBe(true);
|
||||||
|
|
||||||
// Step 3: Verify email
|
// Step 3: Verify email
|
||||||
@@ -270,25 +273,23 @@ describe('User Model - Email Verification', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fail verification with wrong token', async () => {
|
it('should fail verification with wrong token', async () => {
|
||||||
// Generate token
|
// Generate code
|
||||||
const mockToken = 'd'.repeat(64);
|
crypto.randomInt.mockReturnValue(123456);
|
||||||
const mockRandomBytes = Buffer.from('d'.repeat(32));
|
|
||||||
crypto.randomBytes.mockReturnValue(mockRandomBytes);
|
|
||||||
|
|
||||||
await User.prototype.generateVerificationToken.call(mockUser);
|
await User.prototype.generateVerificationToken.call(mockUser);
|
||||||
|
|
||||||
// Try to validate with wrong token
|
// Try to validate with wrong code
|
||||||
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
|
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
|
||||||
|
|
||||||
expect(isValid).toBe(false);
|
expect(isValid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail verification with expired token', async () => {
|
it('should fail verification with expired token', async () => {
|
||||||
// Manually set an expired token
|
// 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
|
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);
|
expect(isValid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ jest.mock('../../../middleware/rateLimiter', () => ({
|
|||||||
loginLimiter: (req, res, next) => next(),
|
loginLimiter: (req, res, next) => next(),
|
||||||
registerLimiter: (req, res, next) => next(),
|
registerLimiter: (req, res, next) => next(),
|
||||||
passwordResetLimiter: (req, res, next) => next(),
|
passwordResetLimiter: (req, res, next) => next(),
|
||||||
|
emailVerificationLimiter: (req, res, next) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../middleware/auth', () => ({
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
optionalAuth: (req, res, next) => next(),
|
optionalAuth: (req, res, next) => next(),
|
||||||
|
authenticateToken: (req, res, next) => {
|
||||||
|
req.user = { id: 'user-123' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../services/email', () => ({
|
jest.mock('../../../services/email', () => ({
|
||||||
@@ -290,7 +295,7 @@ describe('Auth Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
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 () => {
|
it('should reject login with invalid password', async () => {
|
||||||
@@ -311,7 +316,7 @@ describe('Auth Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
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();
|
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,95 +541,147 @@ describe('Auth Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /auth/verify-email', () => {
|
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 = {
|
const mockUser = {
|
||||||
id: 1,
|
id: 'user-123',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
isVerified: false,
|
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),
|
isVerificationTokenValid: jest.fn().mockReturnValue(true),
|
||||||
verifyEmail: jest.fn().mockResolvedValue()
|
verifyEmail: jest.fn().mockResolvedValue()
|
||||||
};
|
};
|
||||||
|
|
||||||
User.findOne.mockResolvedValue(mockUser);
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: 'valid-token' });
|
.send({ code: '123456' });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.message).toBe('Email verified successfully');
|
expect(response.body.message).toBe('Email verified successfully');
|
||||||
expect(response.body.user).toMatchObject({
|
expect(response.body.user).toMatchObject({
|
||||||
id: 1,
|
id: 'user-123',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
isVerified: true
|
isVerified: true
|
||||||
});
|
});
|
||||||
expect(mockUser.verifyEmail).toHaveBeenCalled();
|
expect(mockUser.verifyEmail).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject missing token', async () => {
|
it('should reject missing code', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({});
|
.send({});
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
expect(response.body.error).toBe('Verification token required');
|
expect(response.body.error).toBe('Verification code required');
|
||||||
expect(response.body.code).toBe('TOKEN_REQUIRED');
|
expect(response.body.code).toBe('CODE_REQUIRED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid token', async () => {
|
it('should reject invalid code format (not 6 digits)', async () => {
|
||||||
User.findOne.mockResolvedValue(null);
|
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)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: 'invalid-token' });
|
.send({ code: '123456' });
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(404);
|
||||||
expect(response.body.error).toBe('Invalid verification token');
|
expect(response.body.error).toBe('User not found');
|
||||||
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
|
expect(response.body.code).toBe('USER_NOT_FOUND');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject already verified user', async () => {
|
it('should reject already verified user', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: 1,
|
id: 'user-123',
|
||||||
isVerified: true
|
isVerified: true
|
||||||
};
|
};
|
||||||
|
|
||||||
User.findOne.mockResolvedValue(mockUser);
|
User.findByPk.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: 'some-token' });
|
.send({ code: '123456' });
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
expect(response.body.error).toBe('Email already verified');
|
expect(response.body.error).toBe('Email already verified');
|
||||||
expect(response.body.code).toBe('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 = {
|
const mockUser = {
|
||||||
id: 1,
|
id: 'user-123',
|
||||||
isVerified: false,
|
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)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.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.status).toBe(400);
|
||||||
expect(response.body.error).toContain('expired');
|
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 () => {
|
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)
|
const response = await request(app)
|
||||||
.post('/auth/verify-email')
|
.post('/auth/verify-email')
|
||||||
.send({ token: 'some-token' });
|
.send({ code: '123456' });
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(response.body.error).toBe('Email verification failed. Please try again.');
|
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', () => {
|
describe('POST /auth/forgot-password', () => {
|
||||||
it('should send password reset email for existing user', async () => {
|
it('should send password reset email for existing user', async () => {
|
||||||
const mockUser = {
|
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,
|
model: mockUserModel,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -580,7 +580,7 @@ describe('Items Routes', () => {
|
|||||||
{
|
{
|
||||||
model: mockUserModel,
|
model: mockUserModel,
|
||||||
as: 'renter',
|
as: 'renter',
|
||||||
attributes: ['id', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -648,7 +648,7 @@ describe('Items Routes', () => {
|
|||||||
{
|
{
|
||||||
model: mockUserModel,
|
model: mockUserModel,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: mockUserModel,
|
model: mockUserModel,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ describe('Rentals Routes', () => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'firstName', 'lastName'],
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
@@ -186,7 +186,7 @@ describe('Rentals Routes', () => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'renter',
|
as: 'renter',
|
||||||
attributes: ['id', 'firstName', 'lastName'],
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
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