backend unit test coverage to 80%
This commit is contained in:
@@ -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');
|
||||
@@ -349,3 +349,392 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
355
backend/tests/unit/middleware/stepUpAuth.test.js
Normal file
355
backend/tests/unit/middleware/stepUpAuth.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: '****'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user