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