updating unit and integration tests

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

View File

@@ -1,5 +1,22 @@
module.exports = {
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,

View File

@@ -12,10 +12,10 @@
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
"test": "NODE_ENV=test jest",
"test:watch": "NODE_ENV=test jest --watch",
"test:coverage": "jest --coverage --forceExit --maxWorkers=4",
"test:coverage": "jest --coverage --maxWorkers=1",
"test:unit": "NODE_ENV=test jest tests/unit",
"test:integration": "NODE_ENV=test jest tests/integration",
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2",
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=1",
"db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",

View File

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

View File

@@ -0,0 +1,13 @@
// Integration test setup
// Integration tests use a real database, so we don't mock DATABASE_URL
process.env.NODE_ENV = 'test';
// Ensure JWT secrets are set for integration tests
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret';
// Set other required env vars if not already set
process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || 'test-key';
process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_key';

View File

@@ -20,6 +20,7 @@ jest.mock('../../middleware/rateLimiter', () => ({
passwordResetRequestLimiter: (req, res, next) => next(),
verifyEmailLimiter: (req, res, next) => next(),
resendVerificationLimiter: (req, res, next) => next(),
emailVerificationLimiter: (req, res, next) => next(),
}));
// Mock CSRF protection for tests
@@ -225,7 +226,7 @@ describe('Auth Integration Tests', () => {
})
.expect(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
});
it('should reject login with non-existent email', async () => {
@@ -237,7 +238,7 @@ describe('Auth Integration Tests', () => {
})
.expect(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
});
it('should increment login attempts on failed login', async () => {
@@ -421,7 +422,8 @@ describe('Auth Integration Tests', () => {
describe('POST /auth/verify-email', () => {
let testUser;
let verificationToken;
let verificationCode;
let accessToken;
beforeEach(async () => {
testUser = await createTestUser({
@@ -430,13 +432,21 @@ describe('Auth Integration Tests', () => {
});
await testUser.generateVerificationToken();
await testUser.reload();
verificationToken = testUser.verificationToken;
verificationCode = testUser.verificationToken; // Now a 6-digit code
// Generate access token for authentication
accessToken = jwt.sign(
{ id: testUser.id, email: testUser.email, jwtVersion: testUser.jwtVersion || 0 },
process.env.JWT_ACCESS_SECRET || 'test-access-secret',
{ expiresIn: '15m' }
);
});
it('should verify email with valid token', async () => {
it('should verify email with valid code', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: verificationToken })
.set('Cookie', `accessToken=${accessToken}`)
.send({ code: verificationCode })
.expect(200);
expect(response.body.message).toBe('Email verified successfully');
@@ -448,13 +458,14 @@ describe('Auth Integration Tests', () => {
expect(testUser.verificationToken).toBeNull();
});
it('should reject verification with invalid token', async () => {
it('should reject verification with invalid code', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'invalid-token' })
.set('Cookie', `accessToken=${accessToken}`)
.send({ code: '000000' })
.expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
expect(response.body.code).toBe('VERIFICATION_INVALID');
});
it('should reject verification for already verified user', async () => {
@@ -463,10 +474,11 @@ describe('Auth Integration Tests', () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: verificationToken })
.set('Cookie', `accessToken=${accessToken}`)
.send({ code: verificationCode })
.expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
expect(response.body.code).toBe('ALREADY_VERIFIED');
});
});

View File

@@ -40,6 +40,7 @@ describe('User Model - Email Verification', () => {
lastName: 'User',
verificationToken: null,
verificationTokenExpiry: null,
verificationAttempts: 0,
isVerified: false,
verifiedAt: null,
update: jest.fn().mockImplementation(function(updates) {
@@ -53,18 +54,17 @@ describe('User Model - Email Verification', () => {
});
describe('generateVerificationToken', () => {
it('should generate a random token and set 24-hour expiry', async () => {
const mockRandomBytes = Buffer.from('a'.repeat(32));
const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times
crypto.randomBytes.mockReturnValue(mockRandomBytes);
it('should generate a 6-digit code and set 24-hour expiry', async () => {
const mockCode = 123456;
crypto.randomInt.mockReturnValue(mockCode);
await User.prototype.generateVerificationToken.call(mockUser);
expect(crypto.randomBytes).toHaveBeenCalledWith(32);
expect(crypto.randomInt).toHaveBeenCalledWith(100000, 999999);
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({
verificationToken: mockToken
verificationToken: '123456',
verificationAttempts: 0,
})
);
@@ -77,40 +77,40 @@ describe('User Model - Email Verification', () => {
expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
});
it('should update the user with token and expiry', async () => {
const mockRandomBytes = Buffer.from('b'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
it('should update the user with code and expiry', async () => {
const mockCode = 654321;
crypto.randomInt.mockReturnValue(mockCode);
const result = await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.update).toHaveBeenCalledTimes(1);
expect(result.verificationToken).toBe(mockToken);
expect(result.verificationToken).toBe('654321');
expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
});
it('should generate unique tokens on multiple calls', async () => {
const mockRandomBytes1 = Buffer.from('a'.repeat(32));
const mockRandomBytes2 = Buffer.from('b'.repeat(32));
crypto.randomBytes
.mockReturnValueOnce(mockRandomBytes1)
.mockReturnValueOnce(mockRandomBytes2);
it('should generate unique codes on multiple calls', async () => {
crypto.randomInt
.mockReturnValueOnce(111111)
.mockReturnValueOnce(222222);
await User.prototype.generateVerificationToken.call(mockUser);
const firstToken = mockUser.update.mock.calls[0][0].verificationToken;
const firstCode = mockUser.update.mock.calls[0][0].verificationToken;
await User.prototype.generateVerificationToken.call(mockUser);
const secondToken = mockUser.update.mock.calls[1][0].verificationToken;
const secondCode = mockUser.update.mock.calls[1][0].verificationToken;
expect(firstToken).not.toBe(secondToken);
expect(firstCode).not.toBe(secondCode);
});
});
describe('isVerificationTokenValid', () => {
beforeEach(() => {
// Mock timingSafeEqual to do a simple comparison
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
});
it('should return true for valid token and non-expired time', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
mockUser.verificationToken = validToken;
@@ -131,25 +131,25 @@ describe('User Model - Email Verification', () => {
});
it('should return false for missing expiry', () => {
mockUser.verificationToken = 'valid-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = null;
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token');
const result = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
expect(result).toBe(false);
});
it('should return false for mismatched token', () => {
mockUser.verificationToken = 'correct-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
const result = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
expect(result).toBe(false);
});
it('should return false for expired token', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
mockUser.verificationToken = validToken;
@@ -161,7 +161,7 @@ describe('User Model - Email Verification', () => {
});
it('should return false for token expiring in the past by 1 second', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
mockUser.verificationToken = validToken;
@@ -173,7 +173,7 @@ describe('User Model - Email Verification', () => {
});
it('should handle edge case of token expiring exactly now', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
// Set expiry 1ms in the future to handle timing precision
const nowExpiry = new Date(Date.now() + 1);
@@ -187,7 +187,7 @@ describe('User Model - Email Verification', () => {
});
it('should handle string dates correctly', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date
mockUser.verificationToken = validToken;
@@ -201,7 +201,7 @@ describe('User Model - Email Verification', () => {
describe('verifyEmail', () => {
it('should mark user as verified and clear token fields', async () => {
mockUser.verificationToken = 'some-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date();
await User.prototype.verifyEmail.call(mockUser);
@@ -245,19 +245,22 @@ describe('User Model - Email Verification', () => {
});
describe('Complete verification flow', () => {
beforeEach(() => {
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
});
it('should complete full verification flow successfully', async () => {
// Step 1: Generate verification token
const mockRandomBytes = Buffer.from('c'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
// Step 1: Generate verification code
const mockCode = 999888;
crypto.randomInt.mockReturnValue(mockCode);
await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.verificationToken).toBe(mockToken);
expect(mockUser.verificationToken).toBe('999888');
expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
// Step 2: Validate token
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken);
// Step 2: Validate code
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '999888');
expect(isValid).toBe(true);
// Step 3: Verify email
@@ -270,25 +273,23 @@ describe('User Model - Email Verification', () => {
});
it('should fail verification with wrong token', async () => {
// Generate token
const mockToken = 'd'.repeat(64);
const mockRandomBytes = Buffer.from('d'.repeat(32));
crypto.randomBytes.mockReturnValue(mockRandomBytes);
// Generate code
crypto.randomInt.mockReturnValue(123456);
await User.prototype.generateVerificationToken.call(mockUser);
// Try to validate with wrong token
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
// Try to validate with wrong code
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
expect(isValid).toBe(false);
});
it('should fail verification with expired token', async () => {
// Manually set an expired token
mockUser.verificationToken = 'expired-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'expired-token');
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
expect(isValid).toBe(false);
});

View File

@@ -45,10 +45,15 @@ jest.mock('../../../middleware/rateLimiter', () => ({
loginLimiter: (req, res, next) => next(),
registerLimiter: (req, res, next) => next(),
passwordResetLimiter: (req, res, next) => next(),
emailVerificationLimiter: (req, res, next) => next(),
}));
jest.mock('../../../middleware/auth', () => ({
optionalAuth: (req, res, next) => next(),
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
}));
jest.mock('../../../services/email', () => ({
@@ -290,7 +295,7 @@ describe('Auth Routes', () => {
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
});
it('should reject login with invalid password', async () => {
@@ -311,7 +316,7 @@ describe('Auth Routes', () => {
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
});
@@ -536,95 +541,147 @@ describe('Auth Routes', () => {
});
describe('POST /auth/verify-email', () => {
it('should verify email with valid token', async () => {
it('should verify email with valid 6-digit code', async () => {
const mockUser = {
id: 1,
id: 'user-123',
email: 'test@example.com',
isVerified: false,
verificationToken: 'valid-token',
verificationToken: '123456',
verificationTokenExpiry: new Date(Date.now() + 3600000), // 1 hour from now
verificationAttempts: 0,
isVerificationLocked: jest.fn().mockReturnValue(false),
isVerificationTokenValid: jest.fn().mockReturnValue(true),
verifyEmail: jest.fn().mockResolvedValue()
};
User.findOne.mockResolvedValue(mockUser);
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'valid-token' });
.send({ code: '123456' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Email verified successfully');
expect(response.body.user).toMatchObject({
id: 1,
id: 'user-123',
email: 'test@example.com',
isVerified: true
});
expect(mockUser.verifyEmail).toHaveBeenCalled();
});
it('should reject missing token', async () => {
it('should reject missing code', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Verification token required');
expect(response.body.code).toBe('TOKEN_REQUIRED');
expect(response.body.error).toBe('Verification code required');
expect(response.body.code).toBe('CODE_REQUIRED');
});
it('should reject invalid token', async () => {
User.findOne.mockResolvedValue(null);
it('should reject invalid code format (not 6 digits)', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '12345' }); // Only 5 digits
expect(response.status).toBe(400);
expect(response.body.error).toBe('Verification code must be 6 digits');
expect(response.body.code).toBe('INVALID_CODE_FORMAT');
});
it('should reject when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'invalid-token' });
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid verification token');
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
expect(response.status).toBe(404);
expect(response.body.error).toBe('User not found');
expect(response.body.code).toBe('USER_NOT_FOUND');
});
it('should reject already verified user', async () => {
const mockUser = {
id: 1,
id: 'user-123',
isVerified: true
};
User.findOne.mockResolvedValue(mockUser);
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'some-token' });
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Email already verified');
expect(response.body.code).toBe('ALREADY_VERIFIED');
});
it('should reject expired token', async () => {
it('should reject when too many verification attempts', async () => {
const mockUser = {
id: 1,
id: 'user-123',
isVerified: false,
isVerificationTokenValid: jest.fn().mockReturnValue(false)
isVerificationLocked: jest.fn().mockReturnValue(true)
};
User.findOne.mockResolvedValue(mockUser);
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'expired-token' });
.send({ code: '123456' });
expect(response.status).toBe(429);
expect(response.body.error).toContain('Too many verification attempts');
expect(response.body.code).toBe('TOO_MANY_ATTEMPTS');
});
it('should reject when no verification code exists', async () => {
const mockUser = {
id: 'user-123',
isVerified: false,
verificationToken: null,
isVerificationLocked: jest.fn().mockReturnValue(false)
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('No verification code found');
expect(response.body.code).toBe('NO_CODE');
});
it('should reject expired verification code', async () => {
const mockUser = {
id: 'user-123',
isVerified: false,
verificationToken: '123456',
verificationTokenExpiry: new Date(Date.now() - 3600000), // 1 hour ago (expired)
isVerificationLocked: jest.fn().mockReturnValue(false)
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('expired');
expect(response.body.code).toBe('VERIFICATION_TOKEN_EXPIRED');
expect(response.body.code).toBe('VERIFICATION_EXPIRED');
});
it('should handle verification errors', async () => {
User.findOne.mockRejectedValue(new Error('Database error'));
User.findByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'some-token' });
.send({ code: '123456' });
expect(response.status).toBe(500);
expect(response.body.error).toBe('Email verification failed. Please try again.');
@@ -835,6 +892,48 @@ describe('Auth Routes', () => {
});
});
describe('GET /auth/status', () => {
it('should return authenticated true when user is logged in', async () => {
// The optionalAuth middleware sets req.user if authenticated
// We need to modify the mock for this specific test
const mockUser = {
id: 1,
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
isVerified: true
};
// Create a custom app for this test with user set
const statusApp = express();
statusApp.use(express.json());
statusApp.use((req, res, next) => {
req.user = mockUser;
next();
});
statusApp.use('/auth', authRoutes);
const response = await request(statusApp)
.get('/auth/status');
expect(response.status).toBe(200);
expect(response.body.authenticated).toBe(true);
expect(response.body.user).toMatchObject({
id: 1,
email: 'test@example.com'
});
});
it('should return authenticated false when user is not logged in', async () => {
const response = await request(app)
.get('/auth/status');
expect(response.status).toBe(200);
expect(response.body.authenticated).toBe(false);
expect(response.body.user).toBeUndefined();
});
});
describe('POST /auth/forgot-password', () => {
it('should send password reset email for existing user', async () => {
const mockUser = {

View File

@@ -0,0 +1,328 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
}));
jest.mock('../../../services/conditionCheckService', () => ({
submitConditionCheck: jest.fn(),
getConditionChecks: jest.fn(),
getConditionCheckTimeline: jest.fn(),
getAvailableChecks: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
jest.mock('../../../utils/s3KeyValidator', () => ({
validateS3Keys: jest.fn().mockReturnValue({ valid: true }),
}));
jest.mock('../../../config/imageLimits', () => ({
IMAGE_LIMITS: { conditionChecks: 10 },
}));
const ConditionCheckService = require('../../../services/conditionCheckService');
const { validateS3Keys } = require('../../../utils/s3KeyValidator');
const conditionCheckRoutes = require('../../../routes/conditionChecks');
const app = express();
app.use(express.json());
app.use('/condition-checks', conditionCheckRoutes);
// Error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
describe('Condition Check Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /condition-checks/:rentalId', () => {
const validConditionCheck = {
checkType: 'pre_rental',
notes: 'Item in good condition',
imageFilenames: ['condition-checks/uuid1.jpg', 'condition-checks/uuid2.jpg'],
};
it('should submit a condition check successfully', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'pre_rental',
notes: 'Item in good condition',
imageFilenames: validConditionCheck.imageFilenames,
submittedBy: 'user-123',
createdAt: new Date().toISOString(),
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send(validConditionCheck);
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.conditionCheck).toMatchObject({
id: 'check-1',
checkType: 'pre_rental',
});
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'pre_rental',
'user-123',
validConditionCheck.imageFilenames,
'Item in good condition'
);
});
it('should handle empty image array', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'post_rental',
imageFilenames: [],
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'post_rental',
notes: 'No photos',
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'post_rental',
'user-123',
[],
'No photos'
);
});
it('should reject invalid S3 keys', async () => {
validateS3Keys.mockReturnValueOnce({
valid: false,
error: 'Invalid S3 key format',
invalidKeys: ['invalid-key'],
});
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'pre_rental',
imageFilenames: ['invalid-key'],
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Invalid S3 key format');
expect(response.body.details).toContain('invalid-key');
});
it('should handle service errors', async () => {
ConditionCheckService.submitConditionCheck.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.post('/condition-checks/rental-123')
.send(validConditionCheck);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Rental not found');
});
it('should handle non-array imageFilenames gracefully', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'pre_rental',
imageFilenames: [],
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'pre_rental',
imageFilenames: 'not-an-array',
});
expect(response.status).toBe(201);
// Should convert to empty array
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'pre_rental',
'user-123',
[],
undefined
);
});
});
describe('GET /condition-checks/:rentalId', () => {
it('should return condition checks for a rental', async () => {
const mockChecks = [
{
id: 'check-1',
checkType: 'pre_rental',
notes: 'Good condition',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'check-2',
checkType: 'post_rental',
notes: 'Minor wear',
createdAt: '2024-01-15T00:00:00Z',
},
];
ConditionCheckService.getConditionChecks.mockResolvedValue(mockChecks);
const response = await request(app)
.get('/condition-checks/rental-123');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.conditionChecks).toHaveLength(2);
expect(response.body.conditionChecks[0].checkType).toBe('pre_rental');
expect(ConditionCheckService.getConditionChecks).toHaveBeenCalledWith('rental-123');
});
it('should return empty array when no checks exist', async () => {
ConditionCheckService.getConditionChecks.mockResolvedValue([]);
const response = await request(app)
.get('/condition-checks/rental-456');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.conditionChecks).toHaveLength(0);
});
it('should handle service errors', async () => {
ConditionCheckService.getConditionChecks.mockRejectedValue(
new Error('Database error')
);
const response = await request(app)
.get('/condition-checks/rental-123');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Failed to fetch condition checks');
});
});
describe('GET /condition-checks/:rentalId/timeline', () => {
it('should return condition check timeline', async () => {
const mockTimeline = {
rental: { id: 'rental-123', status: 'completed' },
checks: [
{ type: 'pre_rental', status: 'completed', completedAt: '2024-01-01' },
{ type: 'post_rental', status: 'pending', completedAt: null },
],
};
ConditionCheckService.getConditionCheckTimeline.mockResolvedValue(mockTimeline);
const response = await request(app)
.get('/condition-checks/rental-123/timeline');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.timeline).toMatchObject(mockTimeline);
expect(ConditionCheckService.getConditionCheckTimeline).toHaveBeenCalledWith('rental-123');
});
it('should handle service errors', async () => {
ConditionCheckService.getConditionCheckTimeline.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.get('/condition-checks/rental-123/timeline');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Rental not found');
});
});
describe('GET /condition-checks', () => {
it('should return available checks for current user', async () => {
const mockAvailableChecks = [
{
rentalId: 'rental-1',
itemName: 'Camera',
checkType: 'pre_rental',
dueDate: '2024-01-10',
},
{
rentalId: 'rental-2',
itemName: 'Laptop',
checkType: 'post_rental',
dueDate: '2024-01-15',
},
];
ConditionCheckService.getAvailableChecks.mockResolvedValue(mockAvailableChecks);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.availableChecks).toHaveLength(2);
expect(response.body.availableChecks[0].itemName).toBe('Camera');
expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123');
});
it('should return empty array when no checks available', async () => {
ConditionCheckService.getAvailableChecks.mockResolvedValue([]);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.availableChecks).toHaveLength(0);
});
it('should handle service errors', async () => {
ConditionCheckService.getAvailableChecks.mockRejectedValue(
new Error('Database error')
);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Failed to fetch available checks');
});
});
});

View File

@@ -0,0 +1,813 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies before requiring the route
jest.mock('../../../models', () => ({
ForumPost: {
findAndCountAll: jest.fn(),
findByPk: jest.fn(),
findOne: jest.fn(),
findAll: jest.fn(),
create: jest.fn(),
},
ForumComment: {
findAll: jest.fn(),
findByPk: jest.fn(),
create: jest.fn(),
count: jest.fn(),
destroy: jest.fn(),
},
PostTag: {
findAll: jest.fn(),
findOrCreate: jest.fn(),
create: jest.fn(),
destroy: jest.fn(),
},
User: {
findByPk: jest.fn(),
},
sequelize: {
transaction: jest.fn(() => ({
commit: jest.fn(),
rollback: jest.fn(),
})),
},
}));
jest.mock('sequelize', () => ({
Op: {
or: Symbol('or'),
iLike: Symbol('iLike'),
in: Symbol('in'),
ne: Symbol('ne'),
},
fn: jest.fn((name, col) => ({ fn: name, col })),
col: jest.fn((name) => ({ col: name })),
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123', role: 'user', isVerified: true };
next();
},
requireAdmin: (req, res, next) => {
if (req.user && req.user.role === 'admin') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
},
optionalAuth: (req, res, next) => next(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
jest.mock('../../../services/email', () => ({
forum: {
sendNewPostNotification: jest.fn().mockResolvedValue(),
sendNewCommentNotification: jest.fn().mockResolvedValue(),
sendAnswerAcceptedNotification: jest.fn().mockResolvedValue(),
sendReplyNotification: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../services/googleMapsService', () => ({
geocodeAddress: jest.fn().mockResolvedValue({ lat: 40.7128, lng: -74.006 }),
}));
jest.mock('../../../services/locationService', () => ({
getOrCreateLocation: jest.fn().mockResolvedValue({ id: 'loc-123' }),
}));
jest.mock('../../../utils/s3KeyValidator', () => ({
validateS3Keys: jest.fn().mockReturnValue({ valid: true }),
}));
jest.mock('../../../config/imageLimits', () => ({
IMAGE_LIMITS: { forum: 10 },
}));
const { ForumPost, ForumComment, PostTag, User } = require('../../../models');
const forumRoutes = require('../../../routes/forum');
const app = express();
app.use(express.json());
app.use('/forum', forumRoutes);
// Error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
describe('Forum Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /forum/posts', () => {
it('should return paginated posts', async () => {
const mockPosts = [
{
id: 'post-1',
title: 'Test Post',
content: 'Test content',
category: 'question',
status: 'open',
commentCount: 5,
viewCount: 100,
author: { id: 'user-1', firstName: 'John', lastName: 'Doe' },
tags: [{ id: 'tag-1', name: 'javascript' }],
toJSON: function() { return this; }
},
];
ForumPost.findAndCountAll.mockResolvedValue({
count: 1,
rows: mockPosts,
});
const response = await request(app)
.get('/forum/posts')
.query({ page: 1, limit: 20 });
expect(response.status).toBe(200);
expect(response.body.posts).toHaveLength(1);
expect(response.body.posts[0].title).toBe('Test Post');
expect(response.body.totalPages).toBe(1);
expect(response.body.currentPage).toBe(1);
expect(response.body.totalPosts).toBe(1);
});
it('should filter posts by category', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ category: 'question' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
category: 'question',
}),
})
);
});
it('should search posts by title and content', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ search: 'javascript' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalled();
});
it('should sort posts by different criteria', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ sort: 'comments' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
order: expect.arrayContaining([
['commentCount', 'DESC'],
]),
})
);
});
it('should handle database errors', async () => {
ForumPost.findAndCountAll.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/forum/posts');
expect(response.status).toBe(500);
});
});
describe('GET /forum/posts/:id', () => {
it('should return a single post with comments', async () => {
const mockPost = {
id: 'post-1',
title: 'Test Post',
content: 'Test content',
viewCount: 10,
isDeleted: false,
comments: [],
increment: jest.fn().mockResolvedValue(),
toJSON: function() {
const { increment, toJSON, ...rest } = this;
return rest;
},
author: { id: 'user-1', firstName: 'John', lastName: 'Doe', role: 'user' },
tags: [],
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.get('/forum/posts/post-1');
expect(response.status).toBe(200);
expect(response.body.title).toBe('Test Post');
expect(mockPost.increment).toHaveBeenCalledWith('viewCount', { silent: true });
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.get('/forum/posts/non-existent');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Post not found');
});
it('should return 404 for deleted post (non-admin)', async () => {
const mockPost = {
id: 'post-1',
isDeleted: true,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.get('/forum/posts/post-1');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Post not found');
});
});
describe('POST /forum/posts', () => {
const validPostData = {
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
tags: ['javascript', 'react'],
};
it('should create a new post successfully', async () => {
const mockCreatedPost = {
id: 'new-post-id',
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
authorId: 'user-123',
status: 'open',
};
const mockPostWithDetails = {
...mockCreatedPost,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe' },
tags: [{ id: 'tag-1', tagName: 'javascript' }],
toJSON: function() { return this; },
};
ForumPost.create.mockResolvedValue(mockCreatedPost);
// After create, findByPk is called to get post with details
ForumPost.findByPk.mockResolvedValue(mockPostWithDetails);
const response = await request(app)
.post('/forum/posts')
.send(validPostData);
expect(response.status).toBe(201);
expect(ForumPost.create).toHaveBeenCalledWith(
expect.objectContaining({
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
authorId: 'user-123',
})
);
});
it('should handle Sequelize validation error for missing title', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ content: 'Content without title', category: 'question' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for missing content', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title without content', category: 'question' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for missing category', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title', content: 'Content' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for invalid category', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title', content: 'Content', category: 'invalid' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for title too short', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Hi', content: 'Content', category: 'question' });
expect(response.status).toBe(500);
});
});
describe('PUT /forum/posts/:id', () => {
it('should update own post successfully', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
title: 'Original Title',
content: 'Original content',
isDeleted: false,
setTags: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
PostTag.findOrCreate.mockResolvedValue([{ id: 'tag-1', name: 'updated' }]);
const response = await request(app)
.put('/forum/posts/post-1')
.send({ title: 'Updated Title', content: 'Updated content' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Updated Title',
content: 'Updated content',
})
);
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.put('/forum/posts/non-existent')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
});
it('should return 403 when updating other users post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.put('/forum/posts/post-1')
.send({ title: 'Updated' });
expect(response.status).toBe(403);
expect(response.body.error).toBe('Unauthorized');
});
});
describe('DELETE /forum/posts/:id', () => {
it('should hard delete own post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
destroy: jest.fn().mockResolvedValue(),
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/posts/post-1');
expect(response.status).toBe(204);
expect(mockPost.destroy).toHaveBeenCalled();
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/posts/non-existent');
expect(response.status).toBe(404);
});
it('should return 403 when deleting other users post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/posts/post-1');
expect(response.status).toBe(403);
});
});
describe('POST /forum/posts/:id/comments', () => {
it('should add a comment to a post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'post-author',
isDeleted: false,
status: 'open',
increment: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
};
const mockCreatedComment = {
id: 'comment-1',
content: 'Great post!',
authorId: 'user-123',
postId: 'post-1',
};
const mockCommentWithDetails = {
...mockCreatedComment,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.create.mockResolvedValue(mockCreatedComment);
// After create, findByPk is called to get comment with details
ForumComment.findByPk.mockResolvedValue(mockCommentWithDetails);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Great post!' });
expect(response.status).toBe(201);
expect(ForumComment.create).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Great post!',
authorId: 'user-123',
postId: 'post-1',
})
);
expect(mockPost.increment).toHaveBeenCalledWith('commentCount');
});
it('should handle Sequelize validation error for missing content', async () => {
const mockPost = {
id: 'post-1',
status: 'open',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumComment.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({});
expect(response.status).toBe(500);
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/forum/posts/non-existent/comments')
.send({ content: 'Comment' });
expect(response.status).toBe(404);
});
it('should return 403 when commenting on closed post', async () => {
const mockPost = {
id: 'post-1',
isDeleted: false,
status: 'closed',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Comment' });
expect(response.status).toBe(403);
expect(response.body.error).toContain('closed');
});
it('should support replying to another comment', async () => {
const mockPost = {
id: 'post-1',
authorId: 'post-author',
isDeleted: false,
status: 'open',
increment: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
};
const mockParentComment = {
id: 'parent-comment',
postId: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
const mockCreatedReply = {
id: 'reply-1',
content: 'Reply to comment',
parentCommentId: 'parent-comment',
authorId: 'user-123',
postId: 'post-1',
};
const mockReplyWithDetails = {
...mockCreatedReply,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
// First findByPk call checks parent comment, second gets created comment with details
ForumComment.findByPk
.mockResolvedValueOnce(mockParentComment)
.mockResolvedValueOnce(mockReplyWithDetails);
ForumComment.create.mockResolvedValue(mockCreatedReply);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Reply to comment', parentCommentId: 'parent-comment' });
expect(response.status).toBe(201);
expect(ForumComment.create).toHaveBeenCalledWith(
expect.objectContaining({
parentCommentId: 'parent-comment',
})
);
});
});
describe('GET /forum/my-posts', () => {
it('should return authenticated users posts', async () => {
const mockPosts = [
{
id: 'post-1',
title: 'My Post',
authorId: 'user-123',
toJSON: function() { return this; },
},
];
ForumPost.findAll.mockResolvedValue(mockPosts);
const response = await request(app)
.get('/forum/my-posts');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(ForumPost.findAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
authorId: 'user-123',
}),
})
);
});
});
describe('GET /forum/tags', () => {
it('should return all tags', async () => {
const mockTags = [
{ tagName: 'javascript', count: 10 },
{ tagName: 'react', count: 5 },
];
PostTag.findAll.mockResolvedValue(mockTags);
const response = await request(app)
.get('/forum/tags');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].tagName).toBe('javascript');
});
});
describe('PATCH /forum/posts/:id/status', () => {
it('should update post status by author', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
status: 'open',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/status')
.send({ status: 'answered' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith({
status: 'answered',
closedBy: null,
closedAt: null,
});
});
it('should reject invalid status', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/status')
.send({ status: 'invalid-status' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid status value');
});
});
describe('PUT /forum/comments/:id', () => {
it('should update own comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
content: 'Original',
isDeleted: false,
post: { id: 'post-1', isDeleted: false },
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated content' });
expect(response.status).toBe(200);
expect(mockComment.update).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Updated content',
})
);
});
it('should return 403 when editing other users comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'other-user',
isDeleted: false,
post: { id: 'post-1', isDeleted: false },
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated' });
expect(response.status).toBe(403);
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.put('/forum/comments/non-existent')
.send({ content: 'Updated' });
expect(response.status).toBe(404);
});
});
describe('DELETE /forum/comments/:id', () => {
it('should soft delete own comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
};
const mockPost = {
id: 'post-1',
commentCount: 5,
decrement: jest.fn().mockResolvedValue(),
};
ForumComment.findByPk.mockResolvedValue(mockComment);
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/comments/comment-1');
// Returns 204 No Content on successful delete
expect(response.status).toBe(204);
expect(mockComment.update).toHaveBeenCalledWith({ isDeleted: true });
expect(mockPost.decrement).toHaveBeenCalledWith('commentCount');
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/comments/non-existent');
expect(response.status).toBe(404);
});
it('should return 403 when deleting other users comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'other-user',
isDeleted: false,
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.delete('/forum/comments/comment-1');
expect(response.status).toBe(403);
});
});
});

View File

@@ -199,7 +199,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
limit: 20,
@@ -580,7 +580,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'renter',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -648,7 +648,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
},
{
model: mockUserModel,

View File

@@ -143,7 +143,7 @@ describe('Rentals Routes', () => {
{
model: User,
as: 'owner',
attributes: ['id', 'firstName', 'lastName'],
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
},
],
order: [['createdAt', 'DESC']],
@@ -186,7 +186,7 @@ describe('Rentals Routes', () => {
{
model: User,
as: 'renter',
attributes: ['id', 'firstName', 'lastName'],
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
},
],
order: [['createdAt', 'DESC']],

View File

@@ -0,0 +1,352 @@
const UserService = require('../../../services/UserService');
const { User, UserAddress } = require('../../../models');
const emailServices = require('../../../services/email');
const logger = require('../../../utils/logger');
// Mock dependencies
jest.mock('../../../models', () => ({
User: {
findByPk: jest.fn(),
},
UserAddress: {
create: jest.fn(),
findOne: jest.fn(),
},
}));
jest.mock('../../../services/email', () => ({
auth: {
sendPersonalInfoChangedEmail: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.NODE_ENV = 'test';
});
describe('updateProfile', () => {
const mockUser = {
id: 'user-123',
email: 'original@example.com',
firstName: 'John',
lastName: 'Doe',
address1: '123 Main St',
address2: null,
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'USA',
update: jest.fn().mockResolvedValue(),
};
it('should update user profile successfully', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser) // First call to find user
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' }); // Second call for return
const updateData = { firstName: 'Jane' };
const result = await UserService.updateProfile('user-123', updateData);
expect(User.findByPk).toHaveBeenCalledWith('user-123');
expect(mockUser.update).toHaveBeenCalledWith({ firstName: 'Jane' }, {});
expect(result.firstName).toBe('Jane');
});
it('should throw error when user not found', async () => {
User.findByPk.mockResolvedValue(null);
await expect(UserService.updateProfile('non-existent', { firstName: 'Test' }))
.rejects.toThrow('User not found');
});
it('should trim email and ignore empty email', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { email: ' new@example.com ' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ email: 'new@example.com' }),
{}
);
});
it('should not update email if empty string', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { email: ' ' });
// Email should not be in the update call
expect(mockUser.update).toHaveBeenCalledWith({}, {});
});
it('should convert empty phone to null', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { phone: '' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ phone: null }),
{}
);
});
it('should trim phone number', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { phone: ' 555-1234 ' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ phone: '555-1234' }),
{}
);
});
it('should pass options to update call', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
const mockTransaction = { id: 'tx-123' };
await UserService.updateProfile('user-123', { firstName: 'Jane' }, { transaction: mockTransaction });
expect(mockUser.update).toHaveBeenCalledWith(
expect.any(Object),
{ transaction: mockTransaction }
);
});
it('should not send email notification in test environment', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await UserService.updateProfile('user-123', { firstName: 'Jane' });
// Email should not be sent in test environment
expect(emailServices.auth.sendPersonalInfoChangedEmail).not.toHaveBeenCalled();
});
it('should send email notification in production when personal info changes', async () => {
process.env.NODE_ENV = 'production';
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await UserService.updateProfile('user-123', { firstName: 'Jane' });
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
expect(logger.info).toHaveBeenCalledWith(
'Personal information changed notification sent',
expect.objectContaining({
userId: 'user-123',
changedFields: ['firstName'],
})
);
});
it('should handle email notification failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email service down')
);
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, email: 'new@example.com' });
// Should not throw despite email failure
const result = await UserService.updateProfile('user-123', { email: 'new@example.com' });
expect(result).toBeDefined();
expect(logger.error).toHaveBeenCalledWith(
'Failed to send personal information changed notification',
expect.objectContaining({
error: 'Email service down',
userId: 'user-123',
})
);
});
});
describe('createUserAddress', () => {
const mockUser = {
id: 'user-123',
email: 'user@example.com',
};
const addressData = {
label: 'Home',
address1: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zipCode: '02101',
country: 'USA',
};
it('should create a new address successfully', async () => {
const mockAddress = { id: 'addr-123', ...addressData, userId: 'user-123' };
User.findByPk.mockResolvedValue(mockUser);
UserAddress.create.mockResolvedValue(mockAddress);
const result = await UserService.createUserAddress('user-123', addressData);
expect(User.findByPk).toHaveBeenCalledWith('user-123');
expect(UserAddress.create).toHaveBeenCalledWith({
...addressData,
userId: 'user-123',
});
expect(result.id).toBe('addr-123');
});
it('should throw error when user not found', async () => {
User.findByPk.mockResolvedValue(null);
await expect(UserService.createUserAddress('non-existent', addressData))
.rejects.toThrow('User not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockAddress = { id: 'addr-123', ...addressData };
User.findByPk.mockResolvedValue(mockUser);
UserAddress.create.mockResolvedValue(mockAddress);
await UserService.createUserAddress('user-123', addressData);
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
});
describe('updateUserAddress', () => {
const mockAddress = {
id: 'addr-123',
userId: 'user-123',
address1: '123 Old St',
update: jest.fn().mockResolvedValue(),
};
it('should update address successfully', async () => {
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
const result = await UserService.updateUserAddress('user-123', 'addr-123', {
address1: '789 New St',
});
expect(UserAddress.findOne).toHaveBeenCalledWith({
where: { id: 'addr-123', userId: 'user-123' },
});
expect(mockAddress.update).toHaveBeenCalledWith({ address1: '789 New St' });
expect(result.id).toBe('addr-123');
});
it('should throw error when address not found', async () => {
UserAddress.findOne.mockResolvedValue(null);
await expect(
UserService.updateUserAddress('user-123', 'non-existent', { address1: 'New' })
).rejects.toThrow('Address not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockUser = { id: 'user-123', email: 'user@example.com' };
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue(mockUser);
await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' });
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
it('should handle email failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email failed')
);
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
const result = await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' });
expect(result).toBeDefined();
expect(logger.error).toHaveBeenCalled();
});
});
describe('deleteUserAddress', () => {
const mockAddress = {
id: 'addr-123',
userId: 'user-123',
destroy: jest.fn().mockResolvedValue(),
};
it('should delete address successfully', async () => {
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(UserAddress.findOne).toHaveBeenCalledWith({
where: { id: 'addr-123', userId: 'user-123' },
});
expect(mockAddress.destroy).toHaveBeenCalled();
});
it('should throw error when address not found', async () => {
UserAddress.findOne.mockResolvedValue(null);
await expect(
UserService.deleteUserAddress('user-123', 'non-existent')
).rejects.toThrow('Address not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockUser = { id: 'user-123', email: 'user@example.com' };
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue(mockUser);
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
it('should handle email failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email failed')
);
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
// Should not throw
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(logger.error).toHaveBeenCalled();
});
});
});