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');
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
jest.mock('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,
|
validateLogin,
|
||||||
validateGoogleAuth,
|
validateGoogleAuth,
|
||||||
validateProfileUpdate,
|
validateProfileUpdate,
|
||||||
validatePasswordChange
|
validatePasswordChange,
|
||||||
|
validateForgotPassword,
|
||||||
|
validateResetPassword,
|
||||||
|
validateVerifyResetToken,
|
||||||
|
validateFeedback,
|
||||||
|
validateCoordinatesQuery,
|
||||||
|
validateCoordinatesBody,
|
||||||
|
validateTotpCode,
|
||||||
|
validateEmailOtp,
|
||||||
|
validateRecoveryCode
|
||||||
} = require('../../../middleware/validation');
|
} = require('../../../middleware/validation');
|
||||||
|
|
||||||
describe('Validation Middleware', () => {
|
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
|
// Mock auth middleware
|
||||||
jest.mock('../../../middleware/auth', () => ({
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
authenticateToken: (req, res, next) => {
|
authenticateToken: (req, res, next) => {
|
||||||
if (req.headers.authorization) {
|
if (req.headers.authorization) {
|
||||||
req.user = { id: 1 };
|
req.user = { id: 1, role: mockIsAdmin ? 'admin' : 'user' };
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
res.status(401).json({ error: 'No token provided' });
|
res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
requireVerifiedEmail: (req, res, next) => next(),
|
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()
|
optionalAuth: (req, res, next) => next()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -76,6 +85,7 @@ const mockItemCreate = Item.create;
|
|||||||
const mockItemFindAll = Item.findAll;
|
const mockItemFindAll = Item.findAll;
|
||||||
const mockItemCount = Item.count;
|
const mockItemCount = Item.count;
|
||||||
const mockRentalFindAll = Rental.findAll;
|
const mockRentalFindAll = Rental.findAll;
|
||||||
|
const mockRentalCount = Rental.count;
|
||||||
const mockUserModel = User;
|
const mockUserModel = User;
|
||||||
|
|
||||||
// Set up Express app for testing
|
// Set up Express app for testing
|
||||||
@@ -96,6 +106,7 @@ describe('Items Routes', () => {
|
|||||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
jest.spyOn(console, 'error').mockImplementation();
|
jest.spyOn(console, 'error').mockImplementation();
|
||||||
mockItemCount.mockResolvedValue(1); // Default to not first listing
|
mockItemCount.mockResolvedValue(1); // Default to not first listing
|
||||||
|
mockIsAdmin = true; // Default to admin user
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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: {
|
Item: {
|
||||||
findByPk: jest.fn(),
|
findByPk: jest.fn(),
|
||||||
},
|
},
|
||||||
User: jest.fn(),
|
User: {
|
||||||
|
findByPk: jest.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../middleware/auth', () => ({
|
jest.mock('../../../middleware/auth', () => ({
|
||||||
@@ -39,10 +41,20 @@ jest.mock('../../../services/email', () => ({
|
|||||||
sendRentalCancelledEmail: jest.fn(),
|
sendRentalCancelledEmail: jest.fn(),
|
||||||
sendDamageReportEmail: jest.fn(),
|
sendDamageReportEmail: jest.fn(),
|
||||||
sendLateReturnNotificationEmail: jest.fn(),
|
sendLateReturnNotificationEmail: jest.fn(),
|
||||||
|
sendRentalCompletionEmails: jest.fn().mockResolvedValue(),
|
||||||
|
sendRentalCancellationEmails: jest.fn().mockResolvedValue(),
|
||||||
|
sendAuthenticationRequiredEmail: jest.fn().mockResolvedValue(),
|
||||||
},
|
},
|
||||||
rentalReminder: {
|
rentalReminder: {
|
||||||
sendUpcomingRentalReminder: jest.fn(),
|
sendUpcomingRentalReminder: jest.fn(),
|
||||||
},
|
},
|
||||||
|
customerService: {
|
||||||
|
sendLostItemToCustomerService: jest.fn().mockResolvedValue(),
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
sendPaymentDeclinedNotification: jest.fn().mockResolvedValue(),
|
||||||
|
sendPaymentMethodUpdatedNotification: jest.fn().mockResolvedValue(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../utils/logger', () => ({
|
jest.mock('../../../utils/logger', () => ({
|
||||||
@@ -61,6 +73,7 @@ jest.mock('../../../services/lateReturnService', () => ({
|
|||||||
jest.mock('../../../services/damageAssessmentService', () => ({
|
jest.mock('../../../services/damageAssessmentService', () => ({
|
||||||
assessDamage: jest.fn(),
|
assessDamage: jest.fn(),
|
||||||
processDamageFee: jest.fn(),
|
processDamageFee: jest.fn(),
|
||||||
|
processDamageAssessment: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../utils/feeCalculator', () => ({
|
jest.mock('../../../utils/feeCalculator', () => ({
|
||||||
@@ -89,11 +102,33 @@ jest.mock('../../../services/stripeWebhookService', () => ({
|
|||||||
reconcilePayoutStatuses: jest.fn().mockResolvedValue(),
|
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 { Rental, Item, User } = require('../../../models');
|
||||||
const FeeCalculator = require('../../../utils/feeCalculator');
|
const FeeCalculator = require('../../../utils/feeCalculator');
|
||||||
const RentalDurationCalculator = require('../../../utils/rentalDurationCalculator');
|
const RentalDurationCalculator = require('../../../utils/rentalDurationCalculator');
|
||||||
const RefundService = require('../../../services/refundService');
|
const RefundService = require('../../../services/refundService');
|
||||||
const StripeService = require('../../../services/stripeService');
|
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
|
// Create express app with the router
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -1319,4 +1354,629 @@ describe('Rentals Routes', () => {
|
|||||||
expect(response.body).toEqual({ error: 'Can only update payment method for pending rentals' });
|
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', () => ({
|
jest.mock('../../../models', () => ({
|
||||||
Rental: {
|
Rental: {
|
||||||
findAll: jest.fn(),
|
findAll: jest.fn(),
|
||||||
|
findByPk: jest.fn(),
|
||||||
update: jest.fn()
|
update: jest.fn()
|
||||||
},
|
},
|
||||||
User: jest.fn(),
|
User: jest.fn(),
|
||||||
@@ -12,6 +13,14 @@ jest.mock('../../../services/stripeService', () => ({
|
|||||||
createTransfer: jest.fn()
|
createTransfer: jest.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock email services
|
||||||
|
const mockSendPayoutReceivedEmail = jest.fn();
|
||||||
|
jest.mock('../../../services/email', () => ({
|
||||||
|
rentalFlow: {
|
||||||
|
sendPayoutReceivedEmail: mockSendPayoutReceivedEmail
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('sequelize', () => ({
|
jest.mock('sequelize', () => ({
|
||||||
Op: {
|
Op: {
|
||||||
not: 'not'
|
not: 'not'
|
||||||
@@ -37,6 +46,7 @@ const StripeService = require('../../../services/stripeService');
|
|||||||
|
|
||||||
// Get references to mocks after importing
|
// Get references to mocks after importing
|
||||||
const mockRentalFindAll = Rental.findAll;
|
const mockRentalFindAll = Rental.findAll;
|
||||||
|
const mockRentalFindByPk = Rental.findByPk;
|
||||||
const mockRentalUpdate = Rental.update;
|
const mockRentalUpdate = Rental.update;
|
||||||
const mockUserModel = User;
|
const mockUserModel = User;
|
||||||
const mockItemModel = Item;
|
const mockItemModel = Item;
|
||||||
@@ -755,4 +765,284 @@ describe('PayoutService', () => {
|
|||||||
expect(result.amount).toBe(999999999);
|
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 mockStripePaymentIntentsCreate = jest.fn();
|
||||||
const mockStripeCustomersCreate = jest.fn();
|
const mockStripeCustomersCreate = jest.fn();
|
||||||
const mockStripeCheckoutSessionsCreate = jest.fn();
|
const mockStripeCheckoutSessionsCreate = jest.fn();
|
||||||
|
const mockStripeAccountSessionsCreate = jest.fn();
|
||||||
|
const mockStripePaymentMethodsRetrieve = jest.fn();
|
||||||
|
|
||||||
jest.mock('stripe', () => {
|
jest.mock('stripe', () => {
|
||||||
return jest.fn(() => ({
|
return jest.fn(() => ({
|
||||||
@@ -25,6 +27,9 @@ jest.mock('stripe', () => {
|
|||||||
accountLinks: {
|
accountLinks: {
|
||||||
create: mockStripeAccountLinksCreate
|
create: mockStripeAccountLinksCreate
|
||||||
},
|
},
|
||||||
|
accountSessions: {
|
||||||
|
create: mockStripeAccountSessionsCreate
|
||||||
|
},
|
||||||
transfers: {
|
transfers: {
|
||||||
create: mockStripeTransfersCreate
|
create: mockStripeTransfersCreate
|
||||||
},
|
},
|
||||||
@@ -37,15 +42,20 @@ jest.mock('stripe', () => {
|
|||||||
},
|
},
|
||||||
customers: {
|
customers: {
|
||||||
create: mockStripeCustomersCreate
|
create: mockStripeCustomersCreate
|
||||||
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
retrieve: mockStripePaymentMethodsRetrieve
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockLoggerError = jest.fn();
|
const mockLoggerError = jest.fn();
|
||||||
|
const mockLoggerWarn = jest.fn();
|
||||||
|
const mockLoggerInfo = jest.fn();
|
||||||
jest.mock('../../../utils/logger', () => ({
|
jest.mock('../../../utils/logger', () => ({
|
||||||
error: mockLoggerError,
|
error: mockLoggerError,
|
||||||
info: jest.fn(),
|
info: mockLoggerInfo,
|
||||||
warn: jest.fn(),
|
warn: mockLoggerWarn,
|
||||||
withRequestId: jest.fn(() => ({
|
withRequestId: jest.fn(() => ({
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
info: 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');
|
const StripeService = require('../../../services/stripeService');
|
||||||
|
|
||||||
describe('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,
|
DECLINE_MESSAGES,
|
||||||
} = require('../../../utils/stripeErrors');
|
} = 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('Stripe Errors Utility', () => {
|
||||||
describe('DECLINE_MESSAGES', () => {
|
describe('DECLINE_MESSAGES', () => {
|
||||||
const requiredProperties = [
|
const requiredProperties = [
|
||||||
@@ -252,4 +255,253 @@ describe('Stripe Errors Utility', () => {
|
|||||||
expect(json).not.toHaveProperty('_stripeCode');
|
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