backend unit test coverage to 80%

This commit is contained in:
jackiettran
2026-01-19 19:22:01 -05:00
parent d4362074f5
commit 1923ffc251
8 changed files with 3183 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
const { authenticateToken, requireVerifiedEmail } = require('../../../middleware/auth');
const { authenticateToken, optionalAuth, requireVerifiedEmail, requireAdmin } = require('../../../middleware/auth');
const jwt = require('jsonwebtoken');
jest.mock('jsonwebtoken');
@@ -348,4 +348,393 @@ describe('requireVerifiedEmail Middleware', () => {
});
});
});
});
describe('optionalAuth Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
cookies: {}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
process.env.JWT_ACCESS_SECRET = 'test-secret';
});
describe('No token present', () => {
it('should set req.user to null when no token present', async () => {
req.cookies = {};
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null when cookies is undefined', async () => {
req.cookies = undefined;
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
it('should set req.user to null for empty string token', async () => {
req.cookies.accessToken = '';
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
});
describe('Valid token present', () => {
it('should set req.user when valid token present', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 1 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockResolvedValue(mockUser);
await optionalAuth(req, res, next);
expect(jwt.verify).toHaveBeenCalledWith('validtoken', process.env.JWT_ACCESS_SECRET);
expect(User.findByPk).toHaveBeenCalledWith(1);
expect(req.user).toEqual(mockUser);
expect(next).toHaveBeenCalled();
});
});
describe('Invalid token handling', () => {
it('should set req.user to null for invalid token (no error returned)', async () => {
req.cookies.accessToken = 'invalidtoken';
jwt.verify.mockImplementation(() => {
throw new Error('Invalid token');
});
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null for expired token (no error returned)', async () => {
req.cookies.accessToken = 'expiredtoken';
const error = new Error('jwt expired');
error.name = 'TokenExpiredError';
jwt.verify.mockImplementation(() => {
throw error;
});
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null when token has no user id', async () => {
req.cookies.accessToken = 'tokenwithnoid';
jwt.verify.mockReturnValue({ email: 'test@test.com' }); // Missing id
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
});
describe('User state handling', () => {
it('should set req.user to null for banned user', async () => {
const mockUser = { id: 1, email: 'banned@test.com', isBanned: true, jwtVersion: 1 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockResolvedValue(mockUser);
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null for JWT version mismatch', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 2 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 }); // Old version
User.findByPk.mockResolvedValue(mockUser);
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null when user not found', async () => {
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 999, jwtVersion: 1 });
User.findByPk.mockResolvedValue(null);
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
});
describe('Edge cases', () => {
it('should handle database error gracefully', async () => {
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockRejectedValue(new Error('Database error'));
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
});
describe('requireAdmin Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
user: null
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
});
describe('Admin users', () => {
it('should call next() for admin user', () => {
req.user = {
id: 1,
email: 'admin@test.com',
role: 'admin'
};
requireAdmin(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('should call next() for admin user with additional properties', () => {
req.user = {
id: 1,
email: 'admin@test.com',
role: 'admin',
firstName: 'Admin',
lastName: 'User',
isVerified: true
};
requireAdmin(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('Non-admin users', () => {
it('should return 403 for non-admin user', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: 'user'
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Admin access required',
code: 'INSUFFICIENT_PERMISSIONS'
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 for host role', () => {
req.user = {
id: 1,
email: 'host@test.com',
role: 'host'
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Admin access required',
code: 'INSUFFICIENT_PERMISSIONS'
});
});
it('should return 403 for user with no role property', () => {
req.user = {
id: 1,
email: 'user@test.com'
// role is missing
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Admin access required',
code: 'INSUFFICIENT_PERMISSIONS'
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 for user with empty string role', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: ''
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('No user', () => {
it('should return 401 when user is null', () => {
req.user = null;
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication required',
code: 'NO_AUTH'
});
expect(next).not.toHaveBeenCalled();
});
it('should return 401 when user is undefined', () => {
req.user = undefined;
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication required',
code: 'NO_AUTH'
});
expect(next).not.toHaveBeenCalled();
});
});
describe('Edge cases', () => {
it('should handle case-sensitive role comparison', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: 'Admin' // Capital A
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should handle role with whitespace', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: ' admin ' // With spaces
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
});
});
describe('authenticateToken - Additional Tests', () => {
let req, res, next;
beforeEach(() => {
req = {
cookies: {},
id: 'request-123'
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
process.env.JWT_ACCESS_SECRET = 'test-secret';
});
describe('Banned user', () => {
it('should return 403 USER_BANNED for banned user', async () => {
const mockUser = { id: 1, email: 'banned@test.com', isBanned: true, jwtVersion: 1 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockResolvedValue(mockUser);
await authenticateToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Your account has been suspended. Please contact support for more information.',
code: 'USER_BANNED'
});
expect(next).not.toHaveBeenCalled();
});
});
describe('JWT version mismatch', () => {
it('should return 401 JWT_VERSION_MISMATCH for version mismatch', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 2 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 }); // Old version in token
User.findByPk.mockResolvedValue(mockUser);
await authenticateToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Session expired due to password change. Please log in again.',
code: 'JWT_VERSION_MISMATCH'
});
expect(next).not.toHaveBeenCalled();
});
it('should pass when JWT version matches', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 5 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 5 });
User.findByPk.mockResolvedValue(mockUser);
await authenticateToken(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user).toEqual(mockUser);
});
});
});

View File

@@ -0,0 +1,355 @@
const { requireStepUpAuth } = require('../../../middleware/stepUpAuth');
// Mock TwoFactorService
jest.mock('../../../services/TwoFactorService', () => ({
validateStepUpSession: jest.fn(),
getRemainingRecoveryCodesCount: jest.fn()
}));
// Mock logger
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn()
}));
const TwoFactorService = require('../../../services/TwoFactorService');
const logger = require('../../../utils/logger');
describe('stepUpAuth Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
user: {
id: 1,
email: 'test@test.com',
twoFactorEnabled: true,
twoFactorMethod: 'totp',
recoveryCodesHash: null
}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
});
describe('requireStepUpAuth', () => {
describe('2FA Disabled Path', () => {
it('should call next() when user has 2FA disabled', async () => {
req.user.twoFactorEnabled = false;
const middleware = requireStepUpAuth('sensitive_action');
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(TwoFactorService.validateStepUpSession).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should call next() when twoFactorEnabled is null/falsy', async () => {
req.user.twoFactorEnabled = null;
const middleware = requireStepUpAuth('sensitive_action');
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should call next() when twoFactorEnabled is undefined', async () => {
delete req.user.twoFactorEnabled;
const middleware = requireStepUpAuth('sensitive_action');
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('Valid Step-Up Session', () => {
it('should call next() when validateStepUpSession returns true', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(true);
const middleware = requireStepUpAuth('password_change');
await middleware(req, res, next);
expect(TwoFactorService.validateStepUpSession).toHaveBeenCalledWith(req.user);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('should validate step-up session for different actions', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(true);
const actions = ['password_change', 'delete_account', 'change_email', 'export_data'];
for (const action of actions) {
jest.clearAllMocks();
const middleware = requireStepUpAuth(action);
await middleware(req, res, next);
expect(TwoFactorService.validateStepUpSession).toHaveBeenCalledWith(req.user);
expect(next).toHaveBeenCalled();
}
});
});
describe('Invalid/Expired Session', () => {
it('should return 403 with STEP_UP_REQUIRED code when session is invalid', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('password_change');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'Multi-factor authentication required',
code: 'STEP_UP_REQUIRED',
action: 'password_change'
}));
expect(next).not.toHaveBeenCalled();
});
it('should include action name in response', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const actionName = 'delete_account';
const middleware = requireStepUpAuth(actionName);
await middleware(req, res, next);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
action: actionName
}));
});
it('should log step-up requirement with user ID and action', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('export_data');
await middleware(req, res, next);
expect(logger.info).toHaveBeenCalledWith(
`Step-up authentication required for user ${req.user.id}, action: export_data`
);
});
it('should include methods array in response', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('password_change');
await middleware(req, res, next);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
methods: expect.any(Array)
}));
});
});
describe('Available Methods Logic', () => {
beforeEach(() => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
});
it('should return totp and email for TOTP users', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = null;
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toContain('totp');
expect(response.methods).toContain('email');
});
it('should return email only for email-2FA users', async () => {
req.user.twoFactorMethod = 'email';
req.user.recoveryCodesHash = null;
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).not.toContain('totp');
expect(response.methods).toContain('email');
});
it('should include recovery when recovery codes remain (count > 0)', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = JSON.stringify({ codes: ['code1', 'code2'] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(2);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toContain('recovery');
expect(TwoFactorService.getRemainingRecoveryCodesCount).toHaveBeenCalled();
});
it('should exclude recovery when all codes used (count = 0)', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = JSON.stringify({ codes: [] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(0);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).not.toContain('recovery');
});
it('should exclude recovery when recoveryCodesHash is null', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = null;
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).not.toContain('recovery');
expect(TwoFactorService.getRemainingRecoveryCodesCount).not.toHaveBeenCalled();
});
it('should return correct methods for TOTP user with recovery codes', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = JSON.stringify({ codes: ['abc1-def2', 'ghi3-jkl4'] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(2);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toEqual(['totp', 'email', 'recovery']);
});
it('should return correct methods for email-2FA user with recovery codes', async () => {
req.user.twoFactorMethod = 'email';
req.user.recoveryCodesHash = JSON.stringify({ codes: ['abc1-def2'] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(1);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toEqual(['email', 'recovery']);
});
});
describe('Error Handling', () => {
it('should return 500 when TwoFactorService.validateStepUpSession throws', async () => {
TwoFactorService.validateStepUpSession.mockImplementation(() => {
throw new Error('Service error');
});
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'An error occurred during authentication'
});
expect(next).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
'Step-up auth middleware error:',
expect.any(Error)
);
});
it('should return 500 when TwoFactorService.getRemainingRecoveryCodesCount throws', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
req.user.recoveryCodesHash = JSON.stringify({ codes: [] });
TwoFactorService.getRemainingRecoveryCodesCount.mockImplementation(() => {
throw new Error('Service error');
});
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'An error occurred during authentication'
});
expect(logger.error).toHaveBeenCalled();
});
it('should handle malformed recoveryCodesHash JSON', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
req.user.recoveryCodesHash = 'invalid json {{{';
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'An error occurred during authentication'
});
expect(logger.error).toHaveBeenCalled();
});
it('should log error details when exception occurs', async () => {
const testError = new Error('Test error message');
TwoFactorService.validateStepUpSession.mockImplementation(() => {
throw testError;
});
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(logger.error).toHaveBeenCalledWith(
'Step-up auth middleware error:',
testError
);
});
});
describe('Edge Cases', () => {
it('should handle user with empty string twoFactorMethod', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
req.user.twoFactorMethod = '';
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toContain('email');
expect(response.methods).not.toContain('totp');
});
it('should handle action parameter as empty string', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('');
await middleware(req, res, next);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
action: ''
}));
});
it('should handle various user ID types', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
// Test with string ID
req.user.id = 'user-uuid-123';
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(logger.info).toHaveBeenCalledWith(
'Step-up authentication required for user user-uuid-123, action: action'
);
});
it('should be a factory function that returns middleware', () => {
const middleware = requireStepUpAuth('test_action');
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Should accept req, res, next
});
});
});
});

View File

@@ -46,7 +46,16 @@ const {
validateLogin,
validateGoogleAuth,
validateProfileUpdate,
validatePasswordChange
validatePasswordChange,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
validateFeedback,
validateCoordinatesQuery,
validateCoordinatesBody,
validateTotpCode,
validateEmailOtp,
validateRecoveryCode
} = require('../../../middleware/validation');
describe('Validation Middleware', () => {
@@ -2066,4 +2075,392 @@ describe('Validation Middleware', () => {
});
});
describe('Two-Factor Authentication Validation', () => {
describe('validateTotpCode', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateTotpCode)).toBe(true);
expect(validateTotpCode.length).toBeGreaterThan(1);
expect(validateTotpCode[validateTotpCode.length - 1]).toBe(handleValidationErrors);
});
it('should validate 6-digit numeric format', () => {
const validCodes = ['123456', '000000', '999999', '012345'];
const invalidCodes = ['12345', '1234567', 'abcdef', '12345a', '12 345', ''];
const totpRegex = /^\d{6}$/;
validCodes.forEach(code => {
expect(totpRegex.test(code)).toBe(true);
});
invalidCodes.forEach(code => {
expect(totpRegex.test(code)).toBe(false);
});
});
it('should have at least 2 middleware functions', () => {
expect(validateTotpCode.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateEmailOtp', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateEmailOtp)).toBe(true);
expect(validateEmailOtp.length).toBeGreaterThan(1);
expect(validateEmailOtp[validateEmailOtp.length - 1]).toBe(handleValidationErrors);
});
it('should validate 6-digit numeric format', () => {
const validCodes = ['123456', '000000', '999999', '654321'];
const invalidCodes = ['12345', '1234567', 'abcdef', '12345a', '', ' '];
const otpRegex = /^\d{6}$/;
validCodes.forEach(code => {
expect(otpRegex.test(code)).toBe(true);
});
invalidCodes.forEach(code => {
expect(otpRegex.test(code)).toBe(false);
});
});
it('should have at least 2 middleware functions', () => {
expect(validateEmailOtp.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateRecoveryCode', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateRecoveryCode)).toBe(true);
expect(validateRecoveryCode.length).toBeGreaterThan(1);
expect(validateRecoveryCode[validateRecoveryCode.length - 1]).toBe(handleValidationErrors);
});
it('should validate XXXX-XXXX format', () => {
const validCodes = ['ABCD-1234', 'abcd-efgh', '1234-5678', 'A1B2-C3D4', 'aaaa-bbbb'];
const invalidCodes = ['ABCD1234', 'ABCD-12345', 'ABC-1234', 'ABCD-123', '', 'ABCD--1234', 'ABCD_1234'];
const recoveryRegex = /^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/i;
validCodes.forEach(code => {
expect(recoveryRegex.test(code)).toBe(true);
});
invalidCodes.forEach(code => {
expect(recoveryRegex.test(code)).toBe(false);
});
});
it('should have at least 2 middleware functions', () => {
expect(validateRecoveryCode.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Password Reset Validation', () => {
describe('validateForgotPassword', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateForgotPassword)).toBe(true);
expect(validateForgotPassword.length).toBeGreaterThan(1);
expect(validateForgotPassword[validateForgotPassword.length - 1]).toBe(handleValidationErrors);
});
it('should validate email format', () => {
const validEmails = ['user@example.com', 'test.user@domain.co.uk', 'email@test.org'];
const invalidEmails = ['invalid-email', '@domain.com', 'user@', 'user.domain.com'];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
validEmails.forEach(email => {
expect(emailRegex.test(email)).toBe(true);
});
invalidEmails.forEach(email => {
expect(emailRegex.test(email)).toBe(false);
});
});
it('should enforce email length limits', () => {
const longEmail = 'a'.repeat(250) + '@example.com';
expect(longEmail.length).toBeGreaterThan(255);
const validEmail = 'user@example.com';
expect(validEmail.length).toBeLessThanOrEqual(255);
});
});
describe('validateResetPassword', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateResetPassword)).toBe(true);
expect(validateResetPassword.length).toBeGreaterThan(1);
expect(validateResetPassword[validateResetPassword.length - 1]).toBe(handleValidationErrors);
});
it('should validate 64-character token format', () => {
const valid64CharToken = 'a'.repeat(64);
const shortToken = 'a'.repeat(63);
const longToken = 'a'.repeat(65);
expect(valid64CharToken.length).toBe(64);
expect(shortToken.length).toBe(63);
expect(longToken.length).toBe(65);
});
it('should validate password strength requirements', () => {
const passwordRegex = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?&#^]).{8,}$/;
const strongPasswords = ['Password123!', 'MyStr0ng@Pass', 'Secure1#Test'];
const weakPasswords = ['password', 'PASSWORD123', 'Password', '12345678'];
strongPasswords.forEach(password => {
expect(passwordRegex.test(password)).toBe(true);
});
weakPasswords.forEach(password => {
expect(passwordRegex.test(password)).toBe(false);
});
});
it('should have multiple middleware functions for token and password', () => {
expect(validateResetPassword.length).toBeGreaterThanOrEqual(3);
});
});
describe('validateVerifyResetToken', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateVerifyResetToken)).toBe(true);
expect(validateVerifyResetToken.length).toBeGreaterThan(1);
expect(validateVerifyResetToken[validateVerifyResetToken.length - 1]).toBe(handleValidationErrors);
});
it('should validate 64-character token format', () => {
const valid64CharToken = 'abcdef1234567890'.repeat(4);
expect(valid64CharToken.length).toBe(64);
const shortToken = 'abc123'.repeat(10);
expect(shortToken.length).toBe(60);
const longToken = 'a'.repeat(65);
expect(longToken.length).toBe(65);
});
it('should have at least 2 middleware functions', () => {
expect(validateVerifyResetToken.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Feedback Validation', () => {
describe('validateFeedback', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateFeedback)).toBe(true);
expect(validateFeedback.length).toBeGreaterThan(1);
expect(validateFeedback[validateFeedback.length - 1]).toBe(handleValidationErrors);
});
it('should validate text length (5-5000 chars)', () => {
const tooShort = 'abcd'; // 4 chars
const minValid = 'abcde'; // 5 chars
const maxValid = 'a'.repeat(5000);
const tooLong = 'a'.repeat(5001);
expect(tooShort.length).toBe(4);
expect(minValid.length).toBe(5);
expect(maxValid.length).toBe(5000);
expect(tooLong.length).toBe(5001);
// Validate boundaries
expect(tooShort.length).toBeLessThan(5);
expect(minValid.length).toBeGreaterThanOrEqual(5);
expect(maxValid.length).toBeLessThanOrEqual(5000);
expect(tooLong.length).toBeGreaterThan(5000);
});
it('should have at least 2 middleware functions', () => {
expect(validateFeedback.length).toBeGreaterThanOrEqual(2);
});
it('should include optional URL validation', () => {
// The feedback validation should include url field as optional
expect(validateFeedback.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Coordinates Validation', () => {
describe('validateCoordinatesQuery', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateCoordinatesQuery)).toBe(true);
expect(validateCoordinatesQuery.length).toBeGreaterThan(1);
expect(validateCoordinatesQuery[validateCoordinatesQuery.length - 1]).toBe(handleValidationErrors);
});
it('should validate latitude range (-90 to 90)', () => {
const validLatitudes = [0, 45, -45, 90, -90, 37.7749];
const invalidLatitudes = [91, -91, 180, -180, 1000];
validLatitudes.forEach(lat => {
expect(lat).toBeGreaterThanOrEqual(-90);
expect(lat).toBeLessThanOrEqual(90);
});
invalidLatitudes.forEach(lat => {
expect(lat < -90 || lat > 90).toBe(true);
});
});
it('should validate longitude range (-180 to 180)', () => {
const validLongitudes = [0, 90, -90, 180, -180, -122.4194];
const invalidLongitudes = [181, -181, 360, -360];
validLongitudes.forEach(lng => {
expect(lng).toBeGreaterThanOrEqual(-180);
expect(lng).toBeLessThanOrEqual(180);
});
invalidLongitudes.forEach(lng => {
expect(lng < -180 || lng > 180).toBe(true);
});
});
it('should validate radius range (0.1 to 100)', () => {
const validRadii = [0.1, 1, 50, 100, 0.5, 99.9];
const invalidRadii = [0, 0.05, 100.1, 200, -1];
validRadii.forEach(radius => {
expect(radius).toBeGreaterThanOrEqual(0.1);
expect(radius).toBeLessThanOrEqual(100);
});
invalidRadii.forEach(radius => {
expect(radius < 0.1 || radius > 100).toBe(true);
});
});
it('should have middleware for lat, lng, and radius', () => {
expect(validateCoordinatesQuery.length).toBeGreaterThanOrEqual(4);
});
});
describe('validateCoordinatesBody', () => {
it('should be an array with validation middleware', () => {
expect(Array.isArray(validateCoordinatesBody)).toBe(true);
expect(validateCoordinatesBody.length).toBeGreaterThan(0);
});
it('should validate body latitude range (-90 to 90)', () => {
const validLatitudes = [0, 45.5, -89.99, 90, -90];
const invalidLatitudes = [90.1, -90.1, 100, -100];
validLatitudes.forEach(lat => {
expect(lat).toBeGreaterThanOrEqual(-90);
expect(lat).toBeLessThanOrEqual(90);
});
invalidLatitudes.forEach(lat => {
expect(lat < -90 || lat > 90).toBe(true);
});
});
it('should validate body longitude range (-180 to 180)', () => {
const validLongitudes = [0, 179.99, -179.99, 180, -180];
const invalidLongitudes = [180.1, -180.1, 200, -200];
validLongitudes.forEach(lng => {
expect(lng).toBeGreaterThanOrEqual(-180);
expect(lng).toBeLessThanOrEqual(180);
});
invalidLongitudes.forEach(lng => {
expect(lng < -180 || lng > 180).toBe(true);
});
});
it('should have middleware for latitude and longitude', () => {
expect(validateCoordinatesBody.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Module Exports Completeness', () => {
it('should export all validators from the module', () => {
const validationModule = require('../../../middleware/validation');
// Core middleware
expect(validationModule).toHaveProperty('sanitizeInput');
expect(validationModule).toHaveProperty('handleValidationErrors');
// Auth validators
expect(validationModule).toHaveProperty('validateRegistration');
expect(validationModule).toHaveProperty('validateLogin');
expect(validationModule).toHaveProperty('validateGoogleAuth');
// Profile validators
expect(validationModule).toHaveProperty('validateProfileUpdate');
expect(validationModule).toHaveProperty('validatePasswordChange');
// Password reset validators
expect(validationModule).toHaveProperty('validateForgotPassword');
expect(validationModule).toHaveProperty('validateResetPassword');
expect(validationModule).toHaveProperty('validateVerifyResetToken');
// Feedback validator
expect(validationModule).toHaveProperty('validateFeedback');
// Coordinate validators
expect(validationModule).toHaveProperty('validateCoordinatesQuery');
expect(validationModule).toHaveProperty('validateCoordinatesBody');
// 2FA validators
expect(validationModule).toHaveProperty('validateTotpCode');
expect(validationModule).toHaveProperty('validateEmailOtp');
expect(validationModule).toHaveProperty('validateRecoveryCode');
});
it('should export functions and arrays with correct types', () => {
const validationModule = require('../../../middleware/validation');
// Functions
expect(typeof validationModule.sanitizeInput).toBe('function');
expect(typeof validationModule.handleValidationErrors).toBe('function');
// Arrays (validation chains)
expect(Array.isArray(validationModule.validateRegistration)).toBe(true);
expect(Array.isArray(validationModule.validateLogin)).toBe(true);
expect(Array.isArray(validationModule.validateGoogleAuth)).toBe(true);
expect(Array.isArray(validationModule.validateProfileUpdate)).toBe(true);
expect(Array.isArray(validationModule.validatePasswordChange)).toBe(true);
expect(Array.isArray(validationModule.validateForgotPassword)).toBe(true);
expect(Array.isArray(validationModule.validateResetPassword)).toBe(true);
expect(Array.isArray(validationModule.validateVerifyResetToken)).toBe(true);
expect(Array.isArray(validationModule.validateFeedback)).toBe(true);
expect(Array.isArray(validationModule.validateCoordinatesQuery)).toBe(true);
expect(Array.isArray(validationModule.validateCoordinatesBody)).toBe(true);
expect(Array.isArray(validationModule.validateTotpCode)).toBe(true);
expect(Array.isArray(validationModule.validateEmailOtp)).toBe(true);
expect(Array.isArray(validationModule.validateRecoveryCode)).toBe(true);
});
it('should have all validation arrays end with handleValidationErrors', () => {
const validationModule = require('../../../middleware/validation');
const validatorsWithHandler = [
'validateRegistration',
'validateLogin',
'validateGoogleAuth',
'validateProfileUpdate',
'validatePasswordChange',
'validateForgotPassword',
'validateResetPassword',
'validateVerifyResetToken',
'validateFeedback',
'validateCoordinatesQuery',
'validateTotpCode',
'validateEmailOtp',
'validateRecoveryCode'
];
validatorsWithHandler.forEach(validatorName => {
const validator = validationModule[validatorName];
expect(validator[validator.length - 1]).toBe(validationModule.handleValidationErrors);
});
});
});
});

View File

@@ -29,18 +29,27 @@ jest.mock('../../../models', () => ({
}
}));
// Track whether to simulate admin user
let mockIsAdmin = true;
// Mock auth middleware
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
if (req.headers.authorization) {
req.user = { id: 1 };
req.user = { id: 1, role: mockIsAdmin ? 'admin' : 'user' };
next();
} else {
res.status(401).json({ error: 'No token provided' });
}
},
requireVerifiedEmail: (req, res, next) => next(),
requireAdmin: (req, res, next) => 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()
}));
@@ -76,6 +85,7 @@ const mockItemCreate = Item.create;
const mockItemFindAll = Item.findAll;
const mockItemCount = Item.count;
const mockRentalFindAll = Rental.findAll;
const mockRentalCount = Rental.count;
const mockUserModel = User;
// Set up Express app for testing
@@ -96,6 +106,7 @@ describe('Items Routes', () => {
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
mockItemCount.mockResolvedValue(1); // Default to not first listing
mockIsAdmin = true; // Default to admin user
});
afterEach(() => {
@@ -1404,4 +1415,303 @@ describe('Items Routes', () => {
});
});
});
describe('DELETE /admin/:id (Admin Soft Delete)', () => {
const mockItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: false,
owner: { id: 2, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
update: jest.fn()
};
const mockUpdatedItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: true,
deletedBy: 1,
deletedAt: expect.any(Date),
deletionReason: 'Violates terms of service',
owner: { id: 2, firstName: 'John', lastName: 'Doe' },
deleter: { id: 1, firstName: 'Admin', lastName: 'User' }
};
beforeEach(() => {
mockItem.update.mockReset();
mockRentalCount.mockResolvedValue(0); // No active rentals by default
});
it('should soft delete item as admin with valid reason', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockItem)
.mockResolvedValueOnce(mockUpdatedItem);
mockItem.update.mockResolvedValue();
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(200);
expect(mockItem.update).toHaveBeenCalledWith({
isDeleted: true,
deletedBy: 1,
deletedAt: expect.any(Date),
deletionReason: 'Violates terms of service'
});
});
it('should return updated item with deleter information', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockItem)
.mockResolvedValueOnce(mockUpdatedItem);
mockItem.update.mockResolvedValue();
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(200);
expect(response.body.deleter).toBeDefined();
expect(response.body.isDeleted).toBe(true);
});
it('should return 400 when reason is missing', async () => {
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Deletion reason is required');
});
it('should return 400 when reason is empty', async () => {
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: ' ' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Deletion reason is required');
});
it('should return 401 when not authenticated', async () => {
const response = await request(app)
.delete('/items/admin/1')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('No token provided');
});
it('should return 403 when user is not admin', async () => {
mockIsAdmin = false;
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(403);
expect(response.body.error).toBe('Admin access required');
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/items/admin/999')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Item not found');
});
it('should return 400 when item is already deleted', async () => {
const deletedItem = { ...mockItem, isDeleted: true };
mockItemFindByPk.mockResolvedValue(deletedItem);
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Item is already deleted');
});
it('should return 400 when item has active rentals', async () => {
mockItemFindByPk.mockResolvedValue(mockItem);
mockRentalCount.mockResolvedValue(2);
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Cannot delete item with active or upcoming rentals');
expect(response.body.code).toBe('ACTIVE_RENTALS_EXIST');
expect(response.body.activeRentalsCount).toBe(2);
});
it('should handle database errors', async () => {
mockItemFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(500);
expect(response.body.error).toBe('Database error');
});
it('should handle update errors', async () => {
mockItemFindByPk.mockResolvedValue(mockItem);
mockItem.update.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(500);
expect(response.body.error).toBe('Update failed');
});
});
describe('PATCH /admin/:id/restore (Admin Restore)', () => {
const mockDeletedItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: true,
deletedBy: 1,
deletedAt: new Date(),
deletionReason: 'Violates terms of service',
update: jest.fn()
};
const mockRestoredItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null,
owner: { id: 2, firstName: 'John', lastName: 'Doe' }
};
beforeEach(() => {
mockDeletedItem.update.mockReset();
});
it('should restore soft-deleted item as admin', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockDeletedItem)
.mockResolvedValueOnce(mockRestoredItem);
mockDeletedItem.update.mockResolvedValue();
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(200);
expect(mockDeletedItem.update).toHaveBeenCalledWith({
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null
});
});
it('should clear deletion fields after restore', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockDeletedItem)
.mockResolvedValueOnce(mockRestoredItem);
mockDeletedItem.update.mockResolvedValue();
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(200);
expect(response.body.isDeleted).toBe(false);
expect(response.body.deletedBy).toBeNull();
expect(response.body.deletedAt).toBeNull();
expect(response.body.deletionReason).toBeNull();
});
it('should return 401 when not authenticated', async () => {
const response = await request(app)
.patch('/items/admin/1/restore');
expect(response.status).toBe(401);
expect(response.body.error).toBe('No token provided');
});
it('should return 403 when user is not admin', async () => {
mockIsAdmin = false;
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Admin access required');
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/items/admin/999/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Item not found');
});
it('should return 400 when item is not deleted', async () => {
const activeItem = { ...mockDeletedItem, isDeleted: false };
mockItemFindByPk.mockResolvedValue(activeItem);
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Item is not deleted');
});
it('should handle database errors', async () => {
mockItemFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Database error');
});
it('should handle update errors', async () => {
mockItemFindByPk.mockResolvedValue(mockDeletedItem);
mockDeletedItem.update.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Update failed');
});
});
});

View File

@@ -13,7 +13,9 @@ jest.mock('../../../models', () => ({
Item: {
findByPk: jest.fn(),
},
User: jest.fn(),
User: {
findByPk: jest.fn(),
},
}));
jest.mock('../../../middleware/auth', () => ({
@@ -39,10 +41,20 @@ jest.mock('../../../services/email', () => ({
sendRentalCancelledEmail: jest.fn(),
sendDamageReportEmail: jest.fn(),
sendLateReturnNotificationEmail: jest.fn(),
sendRentalCompletionEmails: jest.fn().mockResolvedValue(),
sendRentalCancellationEmails: jest.fn().mockResolvedValue(),
sendAuthenticationRequiredEmail: jest.fn().mockResolvedValue(),
},
rentalReminder: {
sendUpcomingRentalReminder: jest.fn(),
},
customerService: {
sendLostItemToCustomerService: jest.fn().mockResolvedValue(),
},
payment: {
sendPaymentDeclinedNotification: jest.fn().mockResolvedValue(),
sendPaymentMethodUpdatedNotification: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../utils/logger', () => ({
@@ -61,6 +73,7 @@ jest.mock('../../../services/lateReturnService', () => ({
jest.mock('../../../services/damageAssessmentService', () => ({
assessDamage: jest.fn(),
processDamageFee: jest.fn(),
processDamageAssessment: jest.fn(),
}));
jest.mock('../../../utils/feeCalculator', () => ({
@@ -89,11 +102,33 @@ jest.mock('../../../services/stripeWebhookService', () => ({
reconcilePayoutStatuses: jest.fn().mockResolvedValue(),
}));
jest.mock('../../../services/payoutService', () => ({
triggerPayoutOnCompletion: jest.fn().mockResolvedValue(),
processRentalPayout: jest.fn().mockResolvedValue(),
}));
jest.mock('../../../services/eventBridgeSchedulerService', () => ({
createConditionCheckSchedules: jest.fn().mockResolvedValue(),
}));
// Mock stripe module
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => ({
paymentIntents: {
retrieve: jest.fn(),
},
}));
});
const { Rental, Item, User } = require('../../../models');
const FeeCalculator = require('../../../utils/feeCalculator');
const RentalDurationCalculator = require('../../../utils/rentalDurationCalculator');
const RefundService = require('../../../services/refundService');
const StripeService = require('../../../services/stripeService');
const PayoutService = require('../../../services/payoutService');
const DamageAssessmentService = require('../../../services/damageAssessmentService');
const EventBridgeSchedulerService = require('../../../services/eventBridgeSchedulerService');
const stripe = require('stripe');
// Create express app with the router
const app = express();
@@ -1319,4 +1354,629 @@ describe('Rentals Routes', () => {
expect(response.body).toEqual({ error: 'Can only update payment method for pending rentals' });
});
});
describe('POST /:id/report-damage', () => {
const validUuid = '550e8400-e29b-41d4-a716-446655440000';
const mockRental = {
id: 1,
ownerId: 1,
renterId: 2,
status: 'confirmed',
item: { id: 1, name: 'Test Item' },
};
const mockDamageResult = {
rental: { id: 1, status: 'damaged' },
damageAssessment: {
description: 'Screen cracked',
feeCalculation: { amount: 150 },
},
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
DamageAssessmentService.processDamageAssessment.mockResolvedValue(mockDamageResult);
});
it('should report damage with all required fields', async () => {
const damageData = {
description: 'Screen cracked',
canBeFixed: true,
repairCost: 150,
};
const response = await request(app)
.post('/rentals/1/report-damage')
.send(damageData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(DamageAssessmentService.processDamageAssessment).toHaveBeenCalledWith(
'1',
expect.objectContaining({ description: 'Screen cracked' }),
1
);
});
it('should report damage with optional late return', async () => {
const damageResultWithLate = {
...mockDamageResult,
lateCalculation: { lateFee: 50 },
};
DamageAssessmentService.processDamageAssessment.mockResolvedValue(damageResultWithLate);
const damageData = {
description: 'Screen cracked',
canBeFixed: true,
repairCost: 150,
actualReturnDateTime: new Date().toISOString(),
};
const response = await request(app)
.post('/rentals/1/report-damage')
.send(damageData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('should accept damage report without images', async () => {
const damageData = {
description: 'Screen cracked',
canBeFixed: true,
repairCost: 150,
needsReplacement: false,
};
const response = await request(app)
.post('/rentals/1/report-damage')
.send(damageData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('should return 400 for invalid imageFilenames format', async () => {
const damageData = {
description: 'Screen cracked',
imageFilenames: ['invalid-key.jpg'],
};
const response = await request(app)
.post('/rentals/1/report-damage')
.send(damageData);
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 400 for non-image extensions', async () => {
const damageData = {
description: 'Screen cracked',
imageFilenames: [`damage-reports/${validUuid}.exe`],
};
const response = await request(app)
.post('/rentals/1/report-damage')
.send(damageData);
expect(response.status).toBe(400);
});
it('should return 400 for exceeding max images', async () => {
const tooManyImages = Array(11).fill(0).map((_, i) =>
`damage-reports/550e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}.jpg`
);
const damageData = {
description: 'Screen cracked',
imageFilenames: tooManyImages,
};
const response = await request(app)
.post('/rentals/1/report-damage')
.send(damageData);
expect(response.status).toBe(400);
});
it('should handle damage assessment service errors', async () => {
DamageAssessmentService.processDamageAssessment.mockRejectedValue(
new Error('Assessment failed')
);
const response = await request(app)
.post('/rentals/1/report-damage')
.send({ description: 'Screen cracked' });
expect(response.status).toBe(500);
});
});
describe('GET /:id/payment-client-secret', () => {
const mockRental = {
id: 1,
ownerId: 2,
renterId: 1,
stripePaymentIntentId: 'pi_test123',
renter: { id: 1, stripeCustomerId: 'cus_test123' },
};
let mockStripeInstance;
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
mockStripeInstance = {
paymentIntents: {
retrieve: jest.fn().mockResolvedValue({
client_secret: 'pi_test123_secret_xxx',
status: 'requires_action',
}),
},
};
stripe.mockImplementation(() => mockStripeInstance);
});
it('should return client secret for renter', async () => {
const response = await request(app)
.get('/rentals/1/payment-client-secret');
expect(response.status).toBe(200);
expect(response.body.clientSecret).toBe('pi_test123_secret_xxx');
expect(response.body.status).toBe('requires_action');
});
it('should return payment intent status', async () => {
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({
client_secret: 'pi_test123_secret_xxx',
status: 'succeeded',
});
const response = await request(app)
.get('/rentals/1/payment-client-secret');
expect(response.status).toBe(200);
expect(response.body.status).toBe('succeeded');
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.get('/rentals/999/payment-client-secret');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Rental not found');
});
it('should return 403 for non-renter', async () => {
const nonRenterRental = { ...mockRental, renterId: 3 };
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
const response = await request(app)
.get('/rentals/1/payment-client-secret');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Not authorized');
});
it('should return 400 when no payment intent exists', async () => {
const rentalWithoutPaymentIntent = { ...mockRental, stripePaymentIntentId: null };
mockRentalFindByPk.mockResolvedValue(rentalWithoutPaymentIntent);
const response = await request(app)
.get('/rentals/1/payment-client-secret');
expect(response.status).toBe(400);
expect(response.body.error).toBe('No payment intent found');
});
it('should handle Stripe API errors', async () => {
mockStripeInstance.paymentIntents.retrieve.mockRejectedValue(
new Error('Stripe API error')
);
const response = await request(app)
.get('/rentals/1/payment-client-secret');
expect(response.status).toBe(500);
});
});
describe('POST /:id/complete-payment', () => {
const mockRental = {
id: 1,
ownerId: 2,
renterId: 1,
status: 'pending',
paymentStatus: 'requires_action',
stripePaymentIntentId: 'pi_test123',
totalAmount: 120,
item: { id: 1, name: 'Test Item' },
renter: { id: 1, firstName: 'Alice', lastName: 'Johnson', email: 'alice@example.com', stripeCustomerId: 'cus_test123' },
owner: { id: 2, firstName: 'John', lastName: 'Doe', email: 'john@example.com', stripeConnectedAccountId: 'acct_test123', stripePayoutsEnabled: true },
update: jest.fn().mockResolvedValue(),
};
let mockStripeInstance;
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
mockRental.update.mockReset();
mockStripeInstance = {
paymentIntents: {
retrieve: jest.fn().mockResolvedValue({
status: 'succeeded',
latest_charge: {
payment_method_details: {
type: 'card',
card: { brand: 'visa', last4: '4242' },
},
},
}),
},
};
stripe.mockImplementation(() => mockStripeInstance);
});
it('should complete payment after 3DS authentication', async () => {
const response = await request(app)
.post('/rentals/1/complete-payment');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.rental.status).toBe('confirmed');
expect(response.body.rental.paymentStatus).toBe('paid');
});
it('should update rental to confirmed status', async () => {
await request(app)
.post('/rentals/1/complete-payment');
expect(mockRental.update).toHaveBeenCalledWith({
status: 'confirmed',
paymentStatus: 'paid',
chargedAt: expect.any(Date),
paymentMethodBrand: 'visa',
paymentMethodLast4: '4242',
});
});
it('should create condition check schedules', async () => {
await request(app)
.post('/rentals/1/complete-payment');
expect(EventBridgeSchedulerService.createConditionCheckSchedules).toHaveBeenCalled();
});
it('should trigger payout if owner has payouts enabled', async () => {
await request(app)
.post('/rentals/1/complete-payment');
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockRental);
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.post('/rentals/999/complete-payment');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Rental not found');
});
it('should return 403 for non-renter', async () => {
const nonRenterRental = { ...mockRental, renterId: 3 };
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
const response = await request(app)
.post('/rentals/1/complete-payment');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Not authorized');
});
it('should return 400 when payment status is not requires_action', async () => {
const paidRental = { ...mockRental, paymentStatus: 'paid' };
mockRentalFindByPk.mockResolvedValue(paidRental);
const response = await request(app)
.post('/rentals/1/complete-payment');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid state');
});
it('should return 402 when payment intent not succeeded', async () => {
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({
status: 'requires_action',
});
const response = await request(app)
.post('/rentals/1/complete-payment');
expect(response.status).toBe(402);
expect(response.body.error).toBe('payment_incomplete');
});
it('should handle Stripe API errors', async () => {
mockStripeInstance.paymentIntents.retrieve.mockRejectedValue(
new Error('Stripe API error')
);
const response = await request(app)
.post('/rentals/1/complete-payment');
expect(response.status).toBe(500);
});
it('should handle bank account payment methods', async () => {
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({
status: 'succeeded',
latest_charge: {
payment_method_details: {
type: 'us_bank_account',
us_bank_account: { last4: '6789' },
},
},
});
const response = await request(app)
.post('/rentals/1/complete-payment');
expect(response.status).toBe(200);
expect(mockRental.update).toHaveBeenCalledWith(
expect.objectContaining({
paymentMethodBrand: 'bank_account',
paymentMethodLast4: '6789',
})
);
});
});
describe('POST /:id/mark-return (Additional Cases)', () => {
let mockRental;
const LateReturnService = require('../../../services/lateReturnService');
beforeEach(() => {
mockRental = {
id: 1,
ownerId: 1,
renterId: 2,
status: 'confirmed',
startDateTime: new Date('2024-01-10T10:00:00.000Z'),
endDateTime: new Date('2024-01-15T18:00:00.000Z'),
lateFees: 0,
item: { id: 1, name: 'Test Item' },
update: jest.fn(),
};
// Make update return the modified rental instance
mockRental.update.mockImplementation((updates) => {
Object.assign(mockRental, updates);
return Promise.resolve(mockRental);
});
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should mark item as returned with payout trigger', async () => {
mockRentalFindByPk.mockResolvedValueOnce(mockRental);
const rentalWithDetails = {
...mockRental,
owner: { id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com', stripeConnectedAccountId: 'acct_123' },
renter: { id: 2, firstName: 'Alice', lastName: 'Johnson', email: 'alice@example.com' },
};
mockRentalFindByPk.mockResolvedValueOnce(rentalWithDetails);
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'returned' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(PayoutService.triggerPayoutOnCompletion).toHaveBeenCalledWith('1');
});
it('should mark item as damaged', async () => {
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'damaged' });
expect(response.status).toBe(200);
expect(mockRental.update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'damaged' })
);
});
it('should mark item as returned_late with late fees', async () => {
LateReturnService.processLateReturn.mockResolvedValue({
rental: { ...mockRental, status: 'returned_late', lateFees: 50 },
lateCalculation: { lateFee: 50, hoursLate: 5 },
});
const response = await request(app)
.post('/rentals/1/mark-return')
.send({
status: 'returned_late',
actualReturnDateTime: '2024-01-15T23:00:00.000Z',
});
expect(response.status).toBe(200);
expect(response.body.lateCalculation).toBeDefined();
expect(response.body.lateCalculation.lateFee).toBe(50);
});
it('should require actualReturnDateTime for late returns', async () => {
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'returned_late' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Actual return date/time is required for late returns');
});
it('should mark item as lost with customer service notification', async () => {
User.findByPk = jest.fn().mockResolvedValue({
id: 1,
firstName: 'John',
email: 'john@example.com',
});
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'lost' });
expect(response.status).toBe(200);
expect(mockRental.update).toHaveBeenCalledWith(
expect.objectContaining({
status: 'lost',
itemLostReportedAt: expect.any(Date),
})
);
});
it('should handle damaged with late return combination', async () => {
LateReturnService.processLateReturn.mockResolvedValue({
rental: { ...mockRental, lateFees: 50 },
lateCalculation: { lateFee: 50, hoursLate: 5 },
});
const response = await request(app)
.post('/rentals/1/mark-return')
.send({
status: 'damaged',
actualReturnDateTime: '2024-01-15T23:00:00.000Z',
statusOptions: { returned_late: true },
});
expect(response.status).toBe(200);
expect(mockRental.update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'returned_late_and_damaged' })
);
});
});
describe('PUT /:id/status (3DS Flow)', () => {
const mockRental = {
id: 1,
ownerId: 1,
renterId: 2,
status: 'pending',
stripePaymentMethodId: 'pm_test123',
totalAmount: 120,
item: { id: 1, name: 'Test Item' },
renter: {
id: 2,
username: 'renter1',
firstName: 'Alice',
lastName: 'Johnson',
email: 'alice@example.com',
stripeCustomerId: 'cus_test123',
},
owner: {
id: 1,
firstName: 'John',
lastName: 'Doe',
stripeConnectedAccountId: 'acct_test123',
},
update: jest.fn(),
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
mockRental.update.mockReset();
});
it('should handle payment requiring 3DS authentication', async () => {
StripeService.chargePaymentMethod.mockResolvedValue({
requiresAction: true,
paymentIntentId: 'pi_test_3ds',
clientSecret: 'pi_test_3ds_secret_xxx',
});
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(402);
expect(response.body.error).toBe('authentication_required');
expect(response.body.requiresAction).toBe(true);
});
it('should return 402 with requiresAction flag', async () => {
StripeService.chargePaymentMethod.mockResolvedValue({
requiresAction: true,
paymentIntentId: 'pi_test_3ds',
});
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(402);
expect(response.body.requiresAction).toBe(true);
expect(response.body.rentalId).toBe(1);
});
it('should store payment intent ID for later completion', async () => {
StripeService.chargePaymentMethod.mockResolvedValue({
requiresAction: true,
paymentIntentId: 'pi_test_3ds',
});
await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(mockRental.update).toHaveBeenCalledWith({
stripePaymentIntentId: 'pi_test_3ds',
paymentStatus: 'requires_action',
});
});
it('should set paymentStatus to requires_action', async () => {
StripeService.chargePaymentMethod.mockResolvedValue({
requiresAction: true,
paymentIntentId: 'pi_test_3ds',
});
await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(mockRental.update).toHaveBeenCalledWith(
expect.objectContaining({ paymentStatus: 'requires_action' })
);
});
it('should handle card declined errors', async () => {
const declinedError = new Error('Your card was declined');
declinedError.code = 'card_declined';
StripeService.chargePaymentMethod.mockRejectedValue(declinedError);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(402);
expect(response.body.error).toBe('payment_failed');
expect(response.body.code).toBe('card_declined');
});
it('should handle insufficient funds errors', async () => {
const insufficientError = new Error('Insufficient funds');
insufficientError.code = 'insufficient_funds';
StripeService.chargePaymentMethod.mockRejectedValue(insufficientError);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(402);
expect(response.body.error).toBe('payment_failed');
});
});
});

View File

@@ -2,6 +2,7 @@
jest.mock('../../../models', () => ({
Rental: {
findAll: jest.fn(),
findByPk: jest.fn(),
update: jest.fn()
},
User: jest.fn(),
@@ -12,6 +13,14 @@ jest.mock('../../../services/stripeService', () => ({
createTransfer: jest.fn()
}));
// Mock email services
const mockSendPayoutReceivedEmail = jest.fn();
jest.mock('../../../services/email', () => ({
rentalFlow: {
sendPayoutReceivedEmail: mockSendPayoutReceivedEmail
}
}));
jest.mock('sequelize', () => ({
Op: {
not: 'not'
@@ -37,6 +46,7 @@ const StripeService = require('../../../services/stripeService');
// Get references to mocks after importing
const mockRentalFindAll = Rental.findAll;
const mockRentalFindByPk = Rental.findByPk;
const mockRentalUpdate = Rental.update;
const mockUserModel = User;
const mockItemModel = Item;
@@ -755,4 +765,284 @@ describe('PayoutService', () => {
expect(result.amount).toBe(999999999);
});
});
describe('triggerPayoutOnCompletion', () => {
beforeEach(() => {
mockRentalFindByPk.mockReset();
mockSendPayoutReceivedEmail.mockReset();
});
it('should return rental_not_found when rental does not exist', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const result = await PayoutService.triggerPayoutOnCompletion('nonexistent-rental-id');
expect(result).toEqual({
attempted: false,
success: false,
reason: 'rental_not_found'
});
});
it('should return payment_not_paid when paymentStatus is not paid', async () => {
const mockRental = {
id: 'rental-123',
paymentStatus: 'pending',
payoutStatus: 'pending',
owner: {
stripeConnectedAccountId: 'acct_123',
stripePayoutsEnabled: true
}
};
mockRentalFindByPk.mockResolvedValue(mockRental);
const result = await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(result).toEqual({
attempted: false,
success: false,
reason: 'payment_not_paid'
});
});
it('should return payout_not_pending when payoutStatus is not pending', async () => {
const mockRental = {
id: 'rental-123',
paymentStatus: 'paid',
payoutStatus: 'completed',
owner: {
stripeConnectedAccountId: 'acct_123',
stripePayoutsEnabled: true
}
};
mockRentalFindByPk.mockResolvedValue(mockRental);
const result = await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(result).toEqual({
attempted: false,
success: false,
reason: 'payout_not_pending'
});
});
it('should return no_stripe_account when owner has no stripeConnectedAccountId', async () => {
const mockRental = {
id: 'rental-123',
ownerId: 1,
paymentStatus: 'paid',
payoutStatus: 'pending',
owner: {
stripeConnectedAccountId: null,
stripePayoutsEnabled: false
}
};
mockRentalFindByPk.mockResolvedValue(mockRental);
const result = await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(result).toEqual({
attempted: false,
success: false,
reason: 'no_stripe_account'
});
});
it('should return payouts_not_enabled when owner stripePayoutsEnabled is false', async () => {
const mockRental = {
id: 'rental-123',
ownerId: 1,
paymentStatus: 'paid',
payoutStatus: 'pending',
owner: {
stripeConnectedAccountId: 'acct_123',
stripePayoutsEnabled: false
}
};
mockRentalFindByPk.mockResolvedValue(mockRental);
const result = await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(result).toEqual({
attempted: false,
success: false,
reason: 'payouts_not_enabled'
});
});
it('should successfully process payout when all conditions are met', async () => {
const mockRental = {
id: 'rental-123',
ownerId: 2,
paymentStatus: 'paid',
payoutStatus: 'pending',
payoutAmount: 9500,
totalAmount: 10000,
platformFee: 500,
startDateTime: new Date('2023-01-01T10:00:00Z'),
endDateTime: new Date('2023-01-02T10:00:00Z'),
owner: {
id: 2,
email: 'owner@example.com',
firstName: 'John',
stripeConnectedAccountId: 'acct_123',
stripePayoutsEnabled: true
},
update: jest.fn().mockResolvedValue(true)
};
mockRentalFindByPk.mockResolvedValue(mockRental);
mockCreateTransfer.mockResolvedValue({
id: 'tr_success_123',
amount: 9500
});
mockSendPayoutReceivedEmail.mockResolvedValue(true);
const result = await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(result).toEqual({
attempted: true,
success: true,
transferId: 'tr_success_123',
amount: 9500
});
expect(mockCreateTransfer).toHaveBeenCalled();
expect(mockRental.update).toHaveBeenCalledWith({
payoutStatus: 'completed',
payoutProcessedAt: expect.any(Date),
stripeTransferId: 'tr_success_123'
});
});
it('should return payout_failed on processRentalPayout error', async () => {
const mockRental = {
id: 'rental-123',
ownerId: 2,
paymentStatus: 'paid',
payoutStatus: 'pending',
payoutAmount: 9500,
totalAmount: 10000,
platformFee: 500,
startDateTime: new Date('2023-01-01T10:00:00Z'),
endDateTime: new Date('2023-01-02T10:00:00Z'),
owner: {
id: 2,
email: 'owner@example.com',
firstName: 'John',
stripeConnectedAccountId: 'acct_123',
stripePayoutsEnabled: true
},
update: jest.fn().mockResolvedValue(true)
};
mockRentalFindByPk.mockResolvedValue(mockRental);
mockCreateTransfer.mockRejectedValue(new Error('Stripe transfer failed'));
const result = await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(result).toEqual({
attempted: true,
success: false,
reason: 'payout_failed',
error: 'Stripe transfer failed'
});
});
it('should include Item model in findByPk query', async () => {
mockRentalFindByPk.mockResolvedValue(null);
await PayoutService.triggerPayoutOnCompletion('rental-123');
expect(mockRentalFindByPk).toHaveBeenCalledWith('rental-123', {
include: [
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'email', 'firstName', 'lastName', 'stripeConnectedAccountId', 'stripePayoutsEnabled']
},
{ model: mockItemModel, as: 'item' }
]
});
});
});
describe('processRentalPayout - email notifications', () => {
let mockRental;
beforeEach(() => {
mockSendPayoutReceivedEmail.mockReset();
mockRental = {
id: 1,
ownerId: 2,
payoutStatus: 'pending',
payoutAmount: 9500,
totalAmount: 10000,
platformFee: 500,
startDateTime: new Date('2023-01-01T10:00:00Z'),
endDateTime: new Date('2023-01-02T10:00:00Z'),
owner: {
id: 2,
email: 'owner@example.com',
firstName: 'John',
stripeConnectedAccountId: 'acct_123'
},
update: jest.fn().mockResolvedValue(true)
};
mockCreateTransfer.mockResolvedValue({
id: 'tr_123456789',
amount: 9500
});
});
it('should send payout notification email on successful payout', async () => {
mockSendPayoutReceivedEmail.mockResolvedValue(true);
await PayoutService.processRentalPayout(mockRental);
expect(mockSendPayoutReceivedEmail).toHaveBeenCalledWith(
mockRental.owner,
mockRental
);
expect(mockLoggerInfo).toHaveBeenCalledWith(
'Payout notification email sent to owner',
expect.objectContaining({
rentalId: 1,
ownerId: 2
})
);
});
it('should continue successfully even if email sending fails', async () => {
mockSendPayoutReceivedEmail.mockRejectedValue(new Error('Email service unavailable'));
const result = await PayoutService.processRentalPayout(mockRental);
// Payout should still succeed
expect(result).toEqual({
success: true,
transferId: 'tr_123456789',
amount: 9500
});
// Error should be logged
expect(mockLoggerError).toHaveBeenCalledWith(
'Failed to send payout notification email',
expect.objectContaining({
error: 'Email service unavailable',
rentalId: 1,
ownerId: 2
})
);
});
it('should still update rental status even if email fails', async () => {
mockSendPayoutReceivedEmail.mockRejectedValue(new Error('Email error'));
await PayoutService.processRentalPayout(mockRental);
expect(mockRental.update).toHaveBeenCalledWith({
payoutStatus: 'completed',
payoutProcessedAt: expect.any(Date),
stripeTransferId: 'tr_123456789'
});
});
});
});

View File

@@ -9,6 +9,8 @@ const mockStripeRefundsRetrieve = jest.fn();
const mockStripePaymentIntentsCreate = jest.fn();
const mockStripeCustomersCreate = jest.fn();
const mockStripeCheckoutSessionsCreate = jest.fn();
const mockStripeAccountSessionsCreate = jest.fn();
const mockStripePaymentMethodsRetrieve = jest.fn();
jest.mock('stripe', () => {
return jest.fn(() => ({
@@ -25,6 +27,9 @@ jest.mock('stripe', () => {
accountLinks: {
create: mockStripeAccountLinksCreate
},
accountSessions: {
create: mockStripeAccountSessionsCreate
},
transfers: {
create: mockStripeTransfersCreate
},
@@ -37,15 +42,20 @@ jest.mock('stripe', () => {
},
customers: {
create: mockStripeCustomersCreate
},
paymentMethods: {
retrieve: mockStripePaymentMethodsRetrieve
}
}));
});
const mockLoggerError = jest.fn();
const mockLoggerWarn = jest.fn();
const mockLoggerInfo = jest.fn();
jest.mock('../../../utils/logger', () => ({
error: mockLoggerError,
info: jest.fn(),
warn: jest.fn(),
info: mockLoggerInfo,
warn: mockLoggerWarn,
withRequestId: jest.fn(() => ({
error: jest.fn(),
info: jest.fn(),
@@ -53,6 +63,23 @@ jest.mock('../../../utils/logger', () => ({
})),
}));
// Mock User model
const mockUserFindOne = jest.fn();
const mockUserUpdate = jest.fn();
jest.mock('../../../models', () => ({
User: {
findOne: mockUserFindOne
}
}));
// Mock email services
const mockSendAccountDisconnectedEmail = jest.fn();
jest.mock('../../../services/email', () => ({
payment: {
sendAccountDisconnectedEmail: mockSendAccountDisconnectedEmail
}
}));
const StripeService = require('../../../services/stripeService');
describe('StripeService', () => {
@@ -1158,4 +1185,500 @@ describe('StripeService', () => {
);
});
});
describe('createAccountSession', () => {
it('should create account session successfully', async () => {
const mockSession = {
object: 'account_session',
client_secret: 'acct_sess_secret_123',
expires_at: Date.now() + 3600
};
mockStripeAccountSessionsCreate.mockResolvedValue(mockSession);
const result = await StripeService.createAccountSession('acct_123456789');
expect(mockStripeAccountSessionsCreate).toHaveBeenCalledWith({
account: 'acct_123456789',
components: {
account_onboarding: { enabled: true }
}
});
expect(result).toEqual(mockSession);
});
it('should handle account session creation errors', async () => {
const stripeError = new Error('Account not found');
mockStripeAccountSessionsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createAccountSession('invalid_account'))
.rejects.toThrow('Account not found');
expect(mockLoggerError).toHaveBeenCalledWith(
'Error creating account session',
expect.objectContaining({
error: stripeError.message,
})
);
});
it('should handle invalid account ID', async () => {
const stripeError = new Error('Invalid account ID format');
mockStripeAccountSessionsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createAccountSession(null))
.rejects.toThrow('Invalid account ID format');
});
});
describe('getPaymentMethod', () => {
it('should retrieve payment method successfully', async () => {
const mockPaymentMethod = {
id: 'pm_123456789',
type: 'card',
card: {
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2025
},
customer: 'cus_123456789'
};
mockStripePaymentMethodsRetrieve.mockResolvedValue(mockPaymentMethod);
const result = await StripeService.getPaymentMethod('pm_123456789');
expect(mockStripePaymentMethodsRetrieve).toHaveBeenCalledWith('pm_123456789');
expect(result).toEqual(mockPaymentMethod);
});
it('should handle payment method retrieval errors', async () => {
const stripeError = new Error('Payment method not found');
mockStripePaymentMethodsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getPaymentMethod('pm_invalid'))
.rejects.toThrow('Payment method not found');
expect(mockLoggerError).toHaveBeenCalledWith(
'Error retrieving payment method',
expect.objectContaining({
error: stripeError.message,
paymentMethodId: 'pm_invalid'
})
);
});
it('should handle null payment method ID', async () => {
const stripeError = new Error('Invalid payment method ID');
mockStripePaymentMethodsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getPaymentMethod(null))
.rejects.toThrow('Invalid payment method ID');
});
});
describe('isAccountDisconnectedError', () => {
it('should return true for account_invalid error code', () => {
const error = { code: 'account_invalid', message: 'Account is invalid' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for platform_api_key_expired error code', () => {
const error = { code: 'platform_api_key_expired', message: 'API key expired' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "cannot transfer"', () => {
const error = { code: 'some_code', message: 'You cannot transfer to this account' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "not connected"', () => {
const error = { code: 'some_code', message: 'This account is not connected to your platform' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "no longer connected"', () => {
const error = { code: 'some_code', message: 'This account is no longer connected' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "account has been deauthorized"', () => {
const error = { code: 'some_code', message: 'The account has been deauthorized' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return false for unrelated error codes', () => {
const error = { code: 'card_declined', message: 'Card was declined' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
it('should return false for unrelated error messages', () => {
const error = { code: 'some_code', message: 'Insufficient funds in account' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
it('should handle error with no message', () => {
const error = { code: 'some_code' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
it('should handle error with undefined message', () => {
const error = { code: 'some_code', message: undefined };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
});
describe('handleDisconnectedAccount', () => {
beforeEach(() => {
mockUserFindOne.mockReset();
mockSendAccountDisconnectedEmail.mockReset();
});
it('should clear user stripe connection data', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await StripeService.handleDisconnectedAccount('acct_123456789');
expect(mockUserFindOne).toHaveBeenCalledWith({
where: { stripeConnectedAccountId: 'acct_123456789' }
});
expect(mockUserUpdate).toHaveBeenCalledWith({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false
});
});
it('should send account disconnected email', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await StripeService.handleDisconnectedAccount('acct_123456789');
expect(mockSendAccountDisconnectedEmail).toHaveBeenCalledWith('test@example.com', {
ownerName: 'John',
hasPendingPayouts: true,
pendingPayoutCount: 1
});
});
it('should do nothing when user not found', async () => {
mockUserFindOne.mockResolvedValue(null);
await StripeService.handleDisconnectedAccount('acct_nonexistent');
expect(mockUserFindOne).toHaveBeenCalledWith({
where: { stripeConnectedAccountId: 'acct_nonexistent' }
});
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockSendAccountDisconnectedEmail).not.toHaveBeenCalled();
});
it('should handle email sending errors gracefully', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockRejectedValue(new Error('Email service down'));
// Should not throw
await expect(StripeService.handleDisconnectedAccount('acct_123456789'))
.resolves.not.toThrow();
// Should have logged the error
expect(mockLoggerError).toHaveBeenCalledWith(
'Failed to clean up disconnected account',
expect.objectContaining({
accountId: 'acct_123456789'
})
);
});
it('should handle user update errors gracefully', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: jest.fn().mockRejectedValue(new Error('Database error'))
};
mockUserFindOne.mockResolvedValue(mockUser);
// Should not throw
await expect(StripeService.handleDisconnectedAccount('acct_123456789'))
.resolves.not.toThrow();
expect(mockLoggerError).toHaveBeenCalledWith(
'Failed to clean up disconnected account',
expect.objectContaining({
accountId: 'acct_123456789'
})
);
});
it('should use lastName as fallback when firstName is not available', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: null,
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await StripeService.handleDisconnectedAccount('acct_123456789');
expect(mockSendAccountDisconnectedEmail).toHaveBeenCalledWith('test@example.com', {
ownerName: 'Doe',
hasPendingPayouts: true,
pendingPayoutCount: 1
});
});
});
describe('createTransfer - disconnected account handling', () => {
beforeEach(() => {
mockUserFindOne.mockReset();
mockSendAccountDisconnectedEmail.mockReset();
mockLoggerWarn.mockReset();
});
it('should call handleDisconnectedAccount when account_invalid error occurs', async () => {
const disconnectedError = new Error('The account has been deauthorized');
disconnectedError.code = 'account_invalid';
mockStripeTransfersCreate.mockRejectedValue(disconnectedError);
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_disconnected'
})).rejects.toThrow('The account has been deauthorized');
// Wait for async handleDisconnectedAccount to complete
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockLoggerWarn).toHaveBeenCalledWith(
'Transfer failed - account appears disconnected',
expect.objectContaining({
destination: 'acct_disconnected',
errorCode: 'account_invalid'
})
);
});
it('should still throw the original error after cleanup', async () => {
const disconnectedError = new Error('Cannot transfer to this account');
disconnectedError.code = 'account_invalid';
mockStripeTransfersCreate.mockRejectedValue(disconnectedError);
mockUserFindOne.mockResolvedValue(null);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_disconnected'
})).rejects.toThrow('Cannot transfer to this account');
});
it('should log warning for disconnected account errors', async () => {
const disconnectedError = new Error('This account is no longer connected');
disconnectedError.code = 'some_error';
disconnectedError.type = 'StripeInvalidRequestError';
mockStripeTransfersCreate.mockRejectedValue(disconnectedError);
mockUserFindOne.mockResolvedValue(null);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_disconnected'
})).rejects.toThrow('This account is no longer connected');
expect(mockLoggerWarn).toHaveBeenCalledWith(
'Transfer failed - account appears disconnected',
expect.objectContaining({
destination: 'acct_disconnected'
})
);
});
it('should not call handleDisconnectedAccount for non-disconnection errors', async () => {
const normalError = new Error('Insufficient balance');
normalError.code = 'insufficient_balance';
mockStripeTransfersCreate.mockRejectedValue(normalError);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_123'
})).rejects.toThrow('Insufficient balance');
expect(mockLoggerWarn).not.toHaveBeenCalledWith(
'Transfer failed - account appears disconnected',
expect.any(Object)
);
});
});
describe('chargePaymentMethod - additional cases', () => {
it('should handle authentication_required error and return requires_action', async () => {
const authError = new Error('Authentication required');
authError.code = 'authentication_required';
authError.payment_intent = {
id: 'pi_requires_auth',
client_secret: 'pi_requires_auth_secret'
};
mockStripePaymentIntentsCreate.mockRejectedValue(authError);
const result = await StripeService.chargePaymentMethod(
'pm_123',
50.00,
'cus_123'
);
expect(result.status).toBe('requires_action');
expect(result.requiresAction).toBe(true);
expect(result.paymentIntentId).toBe('pi_requires_auth');
expect(result.clientSecret).toBe('pi_requires_auth_secret');
});
it('should handle us_bank_account payment method type', async () => {
const mockPaymentIntent = {
id: 'pi_bank',
status: 'succeeded',
client_secret: 'pi_bank_secret',
created: Date.now() / 1000,
latest_charge: {
payment_method_details: {
type: 'us_bank_account',
us_bank_account: {
last4: '6789',
bank_name: 'Test Bank'
}
}
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_bank_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toEqual({
type: 'bank',
brand: 'bank_account',
last4: '6789'
});
});
it('should handle unknown payment method type', async () => {
const mockPaymentIntent = {
id: 'pi_unknown',
status: 'succeeded',
client_secret: 'pi_unknown_secret',
created: Date.now() / 1000,
latest_charge: {
payment_method_details: {
type: 'crypto_wallet'
}
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_crypto_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toEqual({
type: 'crypto_wallet',
brand: 'crypto_wallet',
last4: null
});
});
it('should handle payment with no charge details', async () => {
const mockPaymentIntent = {
id: 'pi_no_charge',
status: 'succeeded',
client_secret: 'pi_no_charge_secret',
created: Date.now() / 1000,
latest_charge: null
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toBeNull();
});
it('should handle card with missing details gracefully', async () => {
const mockPaymentIntent = {
id: 'pi_card_no_details',
status: 'succeeded',
client_secret: 'pi_card_secret',
created: Date.now() / 1000,
latest_charge: {
payment_method_details: {
type: 'card',
card: {} // Missing brand and last4
}
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toEqual({
type: 'card',
brand: 'card',
last4: '****'
});
});
});
});

View File

@@ -4,6 +4,9 @@ const {
DECLINE_MESSAGES,
} = require('../../../utils/stripeErrors');
// Access INVALID_REQUEST_MESSAGES for testing via module internals
// We'll test it indirectly through parseStripeError since it's not exported
describe('Stripe Errors Utility', () => {
describe('DECLINE_MESSAGES', () => {
const requiredProperties = [
@@ -252,4 +255,253 @@ describe('Stripe Errors Utility', () => {
expect(json).not.toHaveProperty('_stripeCode');
});
});
describe('parseStripeError - StripeInvalidRequestError', () => {
const requiredProperties = [
'ownerMessage',
'renterMessage',
'canOwnerRetry',
'requiresNewPaymentMethod',
];
test('should parse resource_missing error', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'resource_missing',
message: 'No such payment method',
};
const result = parseStripeError(error);
expect(result.code).toBe('resource_missing');
expect(result.ownerMessage).toBe("The renter's payment method is no longer valid.");
expect(result.requiresNewPaymentMethod).toBe(true);
expect(result.canOwnerRetry).toBe(false);
});
test('should parse payment_method_invalid error', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'payment_method_invalid',
message: 'Payment method is invalid',
};
const result = parseStripeError(error);
expect(result.code).toBe('payment_method_invalid');
expect(result.ownerMessage).toBe("The renter's payment method is invalid.");
expect(result.requiresNewPaymentMethod).toBe(true);
});
test('should parse payment_intent_unexpected_state error', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'payment_intent_unexpected_state',
message: 'Payment intent in unexpected state',
};
const result = parseStripeError(error);
expect(result.code).toBe('payment_intent_unexpected_state');
expect(result.ownerMessage).toBe('This payment is in an unexpected state.');
expect(result.canOwnerRetry).toBe(true);
expect(result.requiresNewPaymentMethod).toBe(false);
});
test('should parse customer_deleted error', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'customer_deleted',
message: 'Customer has been deleted',
};
const result = parseStripeError(error);
expect(result.code).toBe('customer_deleted');
expect(result.ownerMessage).toBe("The renter's payment profile has been deleted.");
expect(result.requiresNewPaymentMethod).toBe(true);
});
test('should handle StripeInvalidRequestError with decline_code', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'some_code',
decline_code: 'insufficient_funds',
message: 'Card declined due to insufficient funds',
};
const result = parseStripeError(error);
expect(result.code).toBe('insufficient_funds');
expect(result.ownerMessage).toBe("The renter's card has insufficient funds.");
});
test('should handle StripeInvalidRequestError with code matching DECLINE_MESSAGES', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'expired_card',
message: 'Card has expired',
};
const result = parseStripeError(error);
expect(result.code).toBe('expired_card');
expect(result.ownerMessage).toBe("The renter's card has expired.");
expect(result.requiresNewPaymentMethod).toBe(true);
});
test('should return default for unhandled StripeInvalidRequestError', () => {
const error = {
type: 'StripeInvalidRequestError',
code: 'unknown_invalid_request_code',
message: 'Some unknown error',
};
const result = parseStripeError(error);
expect(result.code).toBe('unknown_invalid_request_code');
expect(result.ownerMessage).toBe('There was a problem processing this payment.');
expect(result.renterMessage).toBe('There was a problem with your payment method.');
expect(result.requiresNewPaymentMethod).toBe(true);
});
// Verify INVALID_REQUEST_MESSAGES entries have all required properties
describe('INVALID_REQUEST_MESSAGES structure validation', () => {
const invalidRequestCodes = [
'resource_missing',
'payment_method_invalid',
'payment_intent_unexpected_state',
'customer_deleted',
];
test.each(invalidRequestCodes)('%s error returns all required properties', (code) => {
const error = {
type: 'StripeInvalidRequestError',
code: code,
message: 'Test error',
};
const result = parseStripeError(error);
for (const prop of requiredProperties) {
expect(result).toHaveProperty(prop);
}
expect(result).toHaveProperty('_originalMessage');
expect(result).toHaveProperty('_stripeCode');
});
});
});
describe('parseStripeError - edge cases', () => {
test('should handle StripeConnectionError same as StripeAPIError', () => {
const error = {
type: 'StripeConnectionError',
message: 'Network connection failed',
code: 'connection_error',
};
const result = parseStripeError(error);
expect(result.code).toBe('api_error');
expect(result.canOwnerRetry).toBe(true);
expect(result.requiresNewPaymentMethod).toBe(false);
expect(result.ownerMessage).toBe('A temporary error occurred. Please try again.');
});
test('should return unknown_error for completely unknown error type', () => {
const error = {
type: 'UnknownStripeErrorType',
message: 'Something unexpected happened',
code: 'unknown_code',
};
const result = parseStripeError(error);
expect(result.code).toBe('unknown_error');
expect(result.ownerMessage).toBe('The payment could not be processed.');
expect(result.renterMessage).toBe('Your payment could not be processed. Please try a different payment method.');
});
test('should include _originalMessage and _stripeCode in all responses', () => {
// Test StripeCardError
const cardError = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: 'generic_decline',
message: 'Card was declined',
};
const cardResult = parseStripeError(cardError);
expect(cardResult._originalMessage).toBe('Card was declined');
expect(cardResult._stripeCode).toBe('card_declined');
// Test StripeAPIError
const apiError = {
type: 'StripeAPIError',
message: 'API error occurred',
code: 'api_error',
};
const apiResult = parseStripeError(apiError);
expect(apiResult._originalMessage).toBe('API error occurred');
expect(apiResult._stripeCode).toBe('api_error');
// Test StripeRateLimitError
const rateLimitError = {
type: 'StripeRateLimitError',
message: 'Rate limit exceeded',
code: 'rate_limit',
};
const rateLimitResult = parseStripeError(rateLimitError);
expect(rateLimitResult._originalMessage).toBe('Rate limit exceeded');
expect(rateLimitResult._stripeCode).toBe('rate_limit');
// Test unknown error
const unknownError = {
type: 'UnknownType',
message: 'Unknown error',
code: 'unknown',
};
const unknownResult = parseStripeError(unknownError);
expect(unknownResult._originalMessage).toBe('Unknown error');
expect(unknownResult._stripeCode).toBe('unknown');
});
test('should handle error with no message', () => {
const error = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: 'generic_decline',
};
const result = parseStripeError(error);
expect(result.code).toBe('generic_decline');
expect(result._originalMessage).toBeUndefined();
});
test('should handle error with null decline_code', () => {
const error = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: null,
message: 'Card declined',
};
const result = parseStripeError(error);
expect(result.code).toBe('card_declined');
});
test('should handle StripeInvalidRequestError with null code', () => {
const error = {
type: 'StripeInvalidRequestError',
code: null,
message: 'Invalid request',
};
const result = parseStripeError(error);
expect(result.code).toBe('invalid_request');
expect(result.requiresNewPaymentMethod).toBe(true);
});
});
});