backend unit tests
This commit is contained in:
13
backend/tests/setup.js
Normal file
13
backend/tests/setup.js
Normal file
@@ -0,0 +1,13 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.DATABASE_URL = 'postgresql://test';
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_key';
|
||||
|
||||
// Silence console
|
||||
global.console = {
|
||||
...console,
|
||||
log: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn()
|
||||
};
|
||||
194
backend/tests/unit/middleware/auth.test.js
Normal file
194
backend/tests/unit/middleware/auth.test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const { authenticateToken } = require('../../../middleware/auth');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findByPk: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
const { User } = require('../../../models');
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
cookies: {}
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
});
|
||||
|
||||
describe('Valid token', () => {
|
||||
it('should verify valid token from cookie and call next', async () => {
|
||||
const mockUser = { id: 1, email: 'test@test.com' };
|
||||
req.cookies.accessToken = 'validtoken';
|
||||
jwt.verify.mockReturnValue({ id: 1 });
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith('validtoken', process.env.JWT_SECRET);
|
||||
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||
expect(req.user).toEqual(mockUser);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token with valid user', async () => {
|
||||
const mockUser = { id: 2, email: 'user@test.com', firstName: 'Test' };
|
||||
req.cookies.accessToken = 'validtoken2';
|
||||
jwt.verify.mockReturnValue({ id: 2 });
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith('validtoken2', process.env.JWT_SECRET);
|
||||
expect(User.findByPk).toHaveBeenCalledWith(2);
|
||||
expect(req.user).toEqual(mockUser);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid token', () => {
|
||||
it('should return 401 for missing token', async () => {
|
||||
req.cookies = {};
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Access token required',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for invalid token', async () => {
|
||||
req.cookies.accessToken = 'invalidtoken';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for expired token', async () => {
|
||||
req.cookies.accessToken = 'expiredtoken';
|
||||
const error = new Error('jwt expired');
|
||||
error.name = 'TokenExpiredError';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Token expired',
|
||||
code: 'TOKEN_EXPIRED'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for invalid token format (missing user id)', async () => {
|
||||
req.cookies.accessToken = 'tokenwithnoid';
|
||||
jwt.verify.mockReturnValue({ email: 'test@test.com' }); // Missing id
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token format',
|
||||
code: 'INVALID_TOKEN_FORMAT'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when user not found', async () => {
|
||||
req.cookies.accessToken = 'validtoken';
|
||||
jwt.verify.mockReturnValue({ id: 999 });
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'User not found',
|
||||
code: 'USER_NOT_FOUND'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string token', async () => {
|
||||
req.cookies.accessToken = '';
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Access token required',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JWT malformed error', async () => {
|
||||
req.cookies.accessToken = 'malformed.token';
|
||||
const error = new Error('jwt malformed');
|
||||
error.name = 'JsonWebTokenError';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database error when finding user', async () => {
|
||||
req.cookies.accessToken = 'validtoken';
|
||||
jwt.verify.mockReturnValue({ id: 1 });
|
||||
User.findByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined cookies', async () => {
|
||||
req.cookies = undefined;
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Access token required',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
506
backend/tests/unit/middleware/csrf.test.js
Normal file
506
backend/tests/unit/middleware/csrf.test.js
Normal file
@@ -0,0 +1,506 @@
|
||||
const mockTokensInstance = {
|
||||
secretSync: jest.fn().mockReturnValue('mock-secret'),
|
||||
create: jest.fn().mockReturnValue('mock-token-123'),
|
||||
verify: jest.fn().mockReturnValue(true)
|
||||
};
|
||||
|
||||
jest.mock('csrf', () => {
|
||||
return jest.fn().mockImplementation(() => mockTokensInstance);
|
||||
});
|
||||
|
||||
jest.mock('cookie-parser', () => {
|
||||
return jest.fn().mockReturnValue((req, res, next) => next());
|
||||
});
|
||||
|
||||
const { csrfProtection, generateCSRFToken, getCSRFToken } = require('../../../middleware/csrf');
|
||||
|
||||
describe('CSRF Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: {},
|
||||
query: {},
|
||||
cookies: {}
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
cookie: jest.fn(),
|
||||
set: jest.fn(),
|
||||
locals: {}
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('csrfProtection', () => {
|
||||
describe('Safe methods', () => {
|
||||
it('should skip CSRF protection for GET requests', () => {
|
||||
req.method = 'GET';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip CSRF protection for HEAD requests', () => {
|
||||
req.method = 'HEAD';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip CSRF protection for OPTIONS requests', () => {
|
||||
req.method = 'OPTIONS';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token validation', () => {
|
||||
beforeEach(() => {
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
});
|
||||
|
||||
it('should validate token from x-csrf-token header', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate token from request body', () => {
|
||||
req.body.csrfToken = 'mock-token-123';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate token from query parameters', () => {
|
||||
req.query.csrfToken = 'mock-token-123';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer header token over body token', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.body.csrfToken = 'different-token';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer header token over query token', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.query.csrfToken = 'different-token';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer body token over query token', () => {
|
||||
req.body.csrfToken = 'mock-token-123';
|
||||
req.query.csrfToken = 'different-token';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing tokens', () => {
|
||||
it('should return 403 when no token provided', () => {
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when no cookie token provided', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = {};
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when cookies object is missing', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = undefined;
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when both tokens are missing', () => {
|
||||
req.cookies = {};
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token mismatch', () => {
|
||||
it('should return 403 when tokens do not match', () => {
|
||||
req.headers['x-csrf-token'] = 'token-from-header';
|
||||
req.cookies = { 'csrf-token': 'token-from-cookie' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when header token is empty but cookie exists', () => {
|
||||
req.headers['x-csrf-token'] = '';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when cookie token is empty but header exists', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': '' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token verification', () => {
|
||||
beforeEach(() => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
});
|
||||
|
||||
it('should return 403 when token verification fails', () => {
|
||||
mockTokensInstance.verify.mockReturnValue(false);
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_INVALID'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call next when token verification succeeds', () => {
|
||||
mockTokensInstance.verify.mockReturnValue(true);
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle case-insensitive HTTP methods', () => {
|
||||
req.method = 'post';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle PUT requests', () => {
|
||||
req.method = 'PUT';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle DELETE requests', () => {
|
||||
req.method = 'DELETE';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle PATCH requests', () => {
|
||||
req.method = 'PATCH';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCSRFToken', () => {
|
||||
it('should generate token and set cookie with proper options', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set secure flag to false in dev environment', () => {
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set secure flag to true in non-dev environment', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set token in response header', () => {
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'mock-token-123');
|
||||
});
|
||||
|
||||
it('should make token available in res.locals', () => {
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.locals.csrfToken).toBe('mock-token-123');
|
||||
});
|
||||
|
||||
it('should call next after setting up token', () => {
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle test environment', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined NODE_ENV', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCSRFToken', () => {
|
||||
it('should generate token and return it in response', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
||||
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'mock-token-123' });
|
||||
});
|
||||
|
||||
it('should set token in cookie with proper options', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set secure flag to false in dev environment', () => {
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set secure flag to true in production environment', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle test environment', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate new token each time', () => {
|
||||
mockTokensInstance.create
|
||||
.mockReturnValueOnce('token-1')
|
||||
.mockReturnValueOnce('token-2');
|
||||
|
||||
getCSRFToken(req, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-1' });
|
||||
|
||||
getCSRFToken(req, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle complete CSRF flow', () => {
|
||||
// First, generate a token
|
||||
generateCSRFToken(req, res, next);
|
||||
const generatedToken = res.locals.csrfToken;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Now test protection with the generated token
|
||||
req.method = 'POST';
|
||||
req.headers['x-csrf-token'] = generatedToken;
|
||||
req.cookies = { 'csrf-token': generatedToken };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token generation endpoint flow', () => {
|
||||
getCSRFToken(req, res);
|
||||
|
||||
const tokenFromResponse = res.json.mock.calls[0][0].csrfToken;
|
||||
const cookieCall = res.cookie.mock.calls[0];
|
||||
|
||||
expect(cookieCall[0]).toBe('csrf-token');
|
||||
expect(cookieCall[1]).toBe(tokenFromResponse);
|
||||
expect(tokenFromResponse).toBe('mock-token-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
501
backend/tests/unit/middleware/rateLimiter.test.js
Normal file
501
backend/tests/unit/middleware/rateLimiter.test.js
Normal file
@@ -0,0 +1,501 @@
|
||||
// Mock express-rate-limit
|
||||
const mockRateLimitInstance = jest.fn();
|
||||
|
||||
jest.mock('express-rate-limit', () => {
|
||||
const rateLimitFn = jest.fn((config) => {
|
||||
// Store the config for inspection in tests
|
||||
rateLimitFn.lastConfig = config;
|
||||
return mockRateLimitInstance;
|
||||
});
|
||||
rateLimitFn.defaultKeyGenerator = jest.fn().mockReturnValue('127.0.0.1');
|
||||
return rateLimitFn;
|
||||
});
|
||||
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const {
|
||||
placesAutocomplete,
|
||||
placeDetails,
|
||||
geocoding,
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
passwordResetLimiter,
|
||||
generalLimiter,
|
||||
burstProtection,
|
||||
createMapsRateLimiter,
|
||||
createUserBasedRateLimiter
|
||||
} = require('../../../middleware/rateLimiter');
|
||||
|
||||
describe('Rate Limiter Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
ip: '127.0.0.1',
|
||||
user: null
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
set: jest.fn()
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createMapsRateLimiter', () => {
|
||||
it('should create rate limiter with correct configuration', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
windowMs,
|
||||
max,
|
||||
message: {
|
||||
error: message,
|
||||
retryAfter: Math.ceil(windowMs / 1000)
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: expect.any(Function)
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyGenerator', () => {
|
||||
it('should use user ID when user is authenticated', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
const config = rateLimit.lastConfig;
|
||||
|
||||
const reqWithUser = { user: { id: 123 } };
|
||||
const key = config.keyGenerator(reqWithUser);
|
||||
|
||||
expect(key).toBe('user:123');
|
||||
});
|
||||
|
||||
it('should use default IP generator when user is not authenticated', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
const config = rateLimit.lastConfig;
|
||||
|
||||
const reqWithoutUser = { user: null };
|
||||
config.keyGenerator(reqWithoutUser);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(reqWithoutUser);
|
||||
});
|
||||
|
||||
it('should use default IP generator when user has no ID', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
const config = rateLimit.lastConfig;
|
||||
|
||||
const reqWithUserNoId = { user: {} };
|
||||
config.keyGenerator(reqWithUserNoId);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(reqWithUserNoId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate retryAfter correctly', () => {
|
||||
const windowMs = 90000; // 90 seconds
|
||||
const max = 10;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
|
||||
expect(rateLimit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: {
|
||||
error: message,
|
||||
retryAfter: 90 // Math.ceil(90000 / 1000)
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pre-configured rate limiters', () => {
|
||||
describe('placesAutocomplete', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof placesAutocomplete).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeDetails', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof placeDetails).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('geocoding', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof geocoding).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof loginLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof registerLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('passwordResetLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof passwordResetLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generalLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof generalLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserBasedRateLimiter', () => {
|
||||
let userBasedLimiter;
|
||||
const windowMs = 10000; // 10 seconds
|
||||
const max = 5;
|
||||
const message = 'Too many requests';
|
||||
|
||||
beforeEach(() => {
|
||||
userBasedLimiter = createUserBasedRateLimiter(windowMs, max, message);
|
||||
});
|
||||
|
||||
describe('Key generation', () => {
|
||||
it('should use user ID when user is authenticated', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use IP when user is not authenticated', () => {
|
||||
req.user = null;
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.1');
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate limiting logic', () => {
|
||||
it('should allow requests within limit', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Make requests within limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block requests when limit exceeded', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: message,
|
||||
retryAfter: expect.any(Number)
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set correct rate limit headers', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith({
|
||||
'RateLimit-Limit': max,
|
||||
'RateLimit-Remaining': max - 1,
|
||||
'RateLimit-Reset': expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should update remaining count correctly', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// First request
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||
'RateLimit-Remaining': 4
|
||||
}));
|
||||
|
||||
// Second request
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||
'RateLimit-Remaining': 3
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not go below 0 for remaining count', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// Check that remaining doesn't go negative
|
||||
const lastCall = res.set.mock.calls[res.set.mock.calls.length - 1][0];
|
||||
expect(lastCall['RateLimit-Remaining']).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window management', () => {
|
||||
it('should reset count after window expires', () => {
|
||||
req.user = { id: 123 };
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
// Mock time to start of window
|
||||
let currentTime = 1000000000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// Verify limit is reached
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
|
||||
// Move time forward past the window
|
||||
currentTime += windowMs + 1000;
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Should allow requests again
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
|
||||
it('should clean up old entries from store', () => {
|
||||
const originalDateNow = Date.now;
|
||||
let currentTime = 1000000000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Create entries for different users
|
||||
req.user = { id: 1 };
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
req.user = { id: 2 };
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
// Move time forward to expire first entries
|
||||
currentTime += windowMs + 1000;
|
||||
|
||||
req.user = { id: 3 };
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
// The cleanup should have occurred when processing user 3's request
|
||||
// We can't directly test the internal store, but we can verify the behavior
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different users/IPs', () => {
|
||||
it('should maintain separate counts for different users', () => {
|
||||
// User 1 makes max requests
|
||||
req.user = { id: 1 };
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// User 1 should be blocked
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
|
||||
// User 2 should still be allowed
|
||||
jest.clearAllMocks();
|
||||
req.user = { id: 2 };
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should maintain separate counts for different IPs', () => {
|
||||
req.user = null;
|
||||
|
||||
// IP 1 makes max requests
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.1');
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// IP 1 should be blocked
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
|
||||
// IP 2 should still be allowed
|
||||
jest.clearAllMocks();
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.2');
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle undefined user gracefully', () => {
|
||||
req.user = undefined;
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('127.0.0.1');
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle user object without id', () => {
|
||||
req.user = { email: 'test@test.com' };
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('127.0.0.1');
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set correct reset time in ISO format', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
const setCall = res.set.mock.calls[0][0];
|
||||
const resetTime = setCall['RateLimit-Reset'];
|
||||
|
||||
// Should be a valid ISO string
|
||||
expect(() => new Date(resetTime)).not.toThrow();
|
||||
expect(resetTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('should calculate retry after correctly when limit exceeded', () => {
|
||||
req.user = { id: 123 };
|
||||
const originalDateNow = Date.now;
|
||||
const currentTime = 1000000000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
const jsonCall = res.json.mock.calls[0][0];
|
||||
expect(jsonCall.retryAfter).toBe(Math.ceil(windowMs / 1000));
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('burstProtection', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof burstProtection).toBe('function');
|
||||
});
|
||||
|
||||
it('should allow requests within burst limit', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Should allow up to 5 requests in 10 seconds
|
||||
for (let i = 0; i < 5; i++) {
|
||||
jest.clearAllMocks();
|
||||
burstProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block requests when burst limit exceeded', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Exhaust burst limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
burstProtection(req, res, next);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
jest.clearAllMocks();
|
||||
burstProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Too many requests in a short period. Please slow down.',
|
||||
retryAfter: expect.any(Number)
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module exports', () => {
|
||||
it('should export all required rate limiters', () => {
|
||||
const rateLimiterModule = require('../../../middleware/rateLimiter');
|
||||
|
||||
expect(rateLimiterModule).toHaveProperty('placesAutocomplete');
|
||||
expect(rateLimiterModule).toHaveProperty('placeDetails');
|
||||
expect(rateLimiterModule).toHaveProperty('geocoding');
|
||||
expect(rateLimiterModule).toHaveProperty('loginLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('registerLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('passwordResetLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('generalLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('burstProtection');
|
||||
expect(rateLimiterModule).toHaveProperty('createMapsRateLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('createUserBasedRateLimiter');
|
||||
});
|
||||
|
||||
it('should export functions for utility methods', () => {
|
||||
const rateLimiterModule = require('../../../middleware/rateLimiter');
|
||||
|
||||
expect(typeof rateLimiterModule.createMapsRateLimiter).toBe('function');
|
||||
expect(typeof rateLimiterModule.createUserBasedRateLimiter).toBe('function');
|
||||
expect(typeof rateLimiterModule.burstProtection).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
723
backend/tests/unit/middleware/security.test.js
Normal file
723
backend/tests/unit/middleware/security.test.js
Normal file
@@ -0,0 +1,723 @@
|
||||
const {
|
||||
enforceHTTPS,
|
||||
securityHeaders,
|
||||
addRequestId,
|
||||
logSecurityEvent,
|
||||
sanitizeError
|
||||
} = require('../../../middleware/security');
|
||||
|
||||
// Mock crypto module
|
||||
jest.mock('crypto', () => ({
|
||||
randomBytes: jest.fn(() => ({
|
||||
toString: jest.fn(() => 'mocked-hex-string-1234567890abcdef')
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Security Middleware', () => {
|
||||
let req, res, next, consoleSpy, consoleWarnSpy, consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
secure: false,
|
||||
headers: {},
|
||||
protocol: 'http',
|
||||
url: '/test-path',
|
||||
ip: '127.0.0.1',
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
get: jest.fn(),
|
||||
user: null
|
||||
};
|
||||
res = {
|
||||
redirect: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
};
|
||||
next = jest.fn();
|
||||
|
||||
// Mock console methods
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('enforceHTTPS', () => {
|
||||
describe('Development environment', () => {
|
||||
it('should skip HTTPS enforcement in dev environment', () => {
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip HTTPS enforcement in development environment', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Production environment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.FRONTEND_URL = 'example.com';
|
||||
});
|
||||
|
||||
describe('HTTPS detection', () => {
|
||||
it('should detect HTTPS from req.secure', () => {
|
||||
req.secure = true;
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect HTTPS from x-forwarded-proto header', () => {
|
||||
req.headers['x-forwarded-proto'] = 'https';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect HTTPS from req.protocol', () => {
|
||||
req.protocol = 'https';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP to HTTPS redirect', () => {
|
||||
it('should redirect HTTP requests to HTTPS', () => {
|
||||
req.headers.host = 'example.com';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle requests with query parameters', () => {
|
||||
req.url = '/test-path?param=value';
|
||||
req.headers.host = 'example.com';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path?param=value');
|
||||
});
|
||||
|
||||
it('should log warning for host header mismatch', () => {
|
||||
req.headers.host = 'malicious.com';
|
||||
req.ip = '192.168.1.1';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[SECURITY] Host header mismatch during HTTPS redirect:',
|
||||
{
|
||||
requestHost: 'malicious.com',
|
||||
allowedHost: 'example.com',
|
||||
ip: '192.168.1.1',
|
||||
url: '/test-path'
|
||||
}
|
||||
);
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||
});
|
||||
|
||||
it('should not log warning when host matches allowed host', () => {
|
||||
req.headers.host = 'example.com';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||
});
|
||||
|
||||
it('should use FRONTEND_URL as allowed host', () => {
|
||||
process.env.FRONTEND_URL = 'secure-site.com';
|
||||
req.headers.host = 'different.com';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://secure-site.com/test-path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle missing host header', () => {
|
||||
delete req.headers.host;
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path');
|
||||
});
|
||||
|
||||
it('should handle empty URL', () => {
|
||||
req.url = '';
|
||||
req.headers.host = 'example.com';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com');
|
||||
});
|
||||
|
||||
it('should handle root path', () => {
|
||||
req.url = '/';
|
||||
req.headers.host = 'example.com';
|
||||
|
||||
enforceHTTPS(req, res, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('securityHeaders', () => {
|
||||
it('should set X-Content-Type-Options header', () => {
|
||||
securityHeaders(req, res, next);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff');
|
||||
});
|
||||
|
||||
it('should set X-Frame-Options header', () => {
|
||||
securityHeaders(req, res, next);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY');
|
||||
});
|
||||
|
||||
it('should set Referrer-Policy header', () => {
|
||||
securityHeaders(req, res, next);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
});
|
||||
|
||||
it('should set Permissions-Policy header', () => {
|
||||
securityHeaders(req, res, next);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=(self)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should call next after setting headers', () => {
|
||||
securityHeaders(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set all security headers in one call', () => {
|
||||
securityHeaders(req, res, next);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledTimes(4);
|
||||
expect(res.setHeader).toHaveBeenNthCalledWith(1, 'X-Content-Type-Options', 'nosniff');
|
||||
expect(res.setHeader).toHaveBeenNthCalledWith(2, 'X-Frame-Options', 'DENY');
|
||||
expect(res.setHeader).toHaveBeenNthCalledWith(3, 'Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
expect(res.setHeader).toHaveBeenNthCalledWith(4, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRequestId', () => {
|
||||
const crypto = require('crypto');
|
||||
|
||||
it('should generate and set request ID', () => {
|
||||
addRequestId(req, res, next);
|
||||
|
||||
expect(crypto.randomBytes).toHaveBeenCalledWith(16);
|
||||
expect(req.id).toBe('mocked-hex-string-1234567890abcdef');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', 'mocked-hex-string-1234567890abcdef');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate unique IDs for different requests', () => {
|
||||
const mockRandomBytes = require('crypto').randomBytes;
|
||||
|
||||
// First call
|
||||
mockRandomBytes.mockReturnValueOnce({
|
||||
toString: jest.fn(() => 'first-request-id')
|
||||
});
|
||||
|
||||
addRequestId(req, res, next);
|
||||
expect(req.id).toBe('first-request-id');
|
||||
|
||||
// Reset for second call
|
||||
jest.clearAllMocks();
|
||||
const req2 = { ...req };
|
||||
const res2 = { ...res, setHeader: jest.fn() };
|
||||
const next2 = jest.fn();
|
||||
|
||||
// Second call
|
||||
mockRandomBytes.mockReturnValueOnce({
|
||||
toString: jest.fn(() => 'second-request-id')
|
||||
});
|
||||
|
||||
addRequestId(req2, res2, next2);
|
||||
expect(req2.id).toBe('second-request-id');
|
||||
});
|
||||
|
||||
it('should call toString with hex parameter', () => {
|
||||
const mockToString = jest.fn(() => 'hex-string');
|
||||
require('crypto').randomBytes.mockReturnValueOnce({
|
||||
toString: mockToString
|
||||
});
|
||||
|
||||
addRequestId(req, res, next);
|
||||
|
||||
expect(mockToString).toHaveBeenCalledWith('hex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logSecurityEvent', () => {
|
||||
beforeEach(() => {
|
||||
req.id = 'test-request-id';
|
||||
req.ip = '192.168.1.1';
|
||||
req.get = jest.fn((header) => {
|
||||
if (header === 'user-agent') return 'Mozilla/5.0 Test Browser';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Production environment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should log security event with JSON format', () => {
|
||||
const eventType = 'LOGIN_ATTEMPT';
|
||||
const details = { username: 'testuser', success: false };
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', expect.any(String));
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData).toEqual({
|
||||
timestamp: expect.any(String),
|
||||
eventType: 'LOGIN_ATTEMPT',
|
||||
requestId: 'test-request-id',
|
||||
ip: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 Test Browser',
|
||||
userId: 'anonymous',
|
||||
username: 'testuser',
|
||||
success: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should include user ID when user is authenticated', () => {
|
||||
req.user = { id: 123 };
|
||||
const eventType = 'DATA_ACCESS';
|
||||
const details = { resource: '/api/users' };
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData.userId).toBe(123);
|
||||
});
|
||||
|
||||
it('should handle missing request ID', () => {
|
||||
delete req.id;
|
||||
const eventType = 'SUSPICIOUS_ACTIVITY';
|
||||
const details = { reason: 'Multiple failed attempts' };
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData.requestId).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should handle missing IP address', () => {
|
||||
delete req.ip;
|
||||
req.connection.remoteAddress = '10.0.0.1';
|
||||
const eventType = 'IP_CHECK';
|
||||
const details = { status: 'blocked' };
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData.ip).toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
it('should include ISO timestamp', () => {
|
||||
const eventType = 'TEST_EVENT';
|
||||
const details = {};
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-production environment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
});
|
||||
|
||||
it('should log security event with simple format', () => {
|
||||
const eventType = 'LOGIN_ATTEMPT';
|
||||
const details = { username: 'testuser', success: false };
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[SECURITY]',
|
||||
'LOGIN_ATTEMPT',
|
||||
{ username: 'testuser', success: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('should not log JSON in development', () => {
|
||||
const eventType = 'TEST_EVENT';
|
||||
const details = { test: true };
|
||||
|
||||
logSecurityEvent(eventType, details, req);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', 'TEST_EVENT', { test: true });
|
||||
// Ensure it's not JSON.stringify format
|
||||
expect(consoleSpy).not.toHaveBeenCalledWith('[SECURITY]', expect.stringMatching(/^{.*}$/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle missing user-agent header', () => {
|
||||
req.get.mockReturnValue(null);
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
logSecurityEvent('TEST', {}, req);
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData.userAgent).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty details object', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
logSecurityEvent('EMPTY_DETAILS', {}, req);
|
||||
|
||||
const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]);
|
||||
expect(loggedData.eventType).toBe('EMPTY_DETAILS');
|
||||
expect(Object.keys(loggedData)).toContain('timestamp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeError', () => {
|
||||
beforeEach(() => {
|
||||
req.id = 'test-request-id';
|
||||
req.user = { id: 123 };
|
||||
});
|
||||
|
||||
describe('Error logging', () => {
|
||||
it('should log full error details internally', () => {
|
||||
const error = new Error('Database connection failed');
|
||||
error.stack = 'Error: Database connection failed\n at /app/db.js:10:5';
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', {
|
||||
requestId: 'test-request-id',
|
||||
error: 'Database connection failed',
|
||||
stack: 'Error: Database connection failed\n at /app/db.js:10:5',
|
||||
userId: 123
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing user in logging', () => {
|
||||
req.user = null;
|
||||
const error = new Error('Test error');
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', {
|
||||
requestId: 'test-request-id',
|
||||
error: 'Test error',
|
||||
stack: error.stack,
|
||||
userId: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client error responses (4xx)', () => {
|
||||
it('should handle 400 Bad Request errors', () => {
|
||||
const error = new Error('Invalid input data');
|
||||
error.status = 400;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid input data',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 400 errors with default message', () => {
|
||||
const error = new Error();
|
||||
error.status = 400;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Bad Request',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 401 Unauthorized errors', () => {
|
||||
const error = new Error('Token expired');
|
||||
error.status = 401;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Unauthorized',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 403 Forbidden errors', () => {
|
||||
const error = new Error('Access denied');
|
||||
error.status = 403;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 404 Not Found errors', () => {
|
||||
const error = new Error('User not found');
|
||||
error.status = 404;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Not Found',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server error responses (5xx)', () => {
|
||||
describe('Development environment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
});
|
||||
|
||||
it('should include detailed error message and stack trace', () => {
|
||||
const error = new Error('Database connection failed');
|
||||
error.status = 500;
|
||||
error.stack = 'Error: Database connection failed\n at /app/db.js:10:5';
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Database connection failed',
|
||||
requestId: 'test-request-id',
|
||||
stack: 'Error: Database connection failed\n at /app/db.js:10:5'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle dev environment check', () => {
|
||||
process.env.NODE_ENV = 'dev';
|
||||
const error = new Error('Test error');
|
||||
error.status = 500;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Test error',
|
||||
requestId: 'test-request-id',
|
||||
stack: error.stack
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default status 500 when not specified', () => {
|
||||
const error = new Error('Unhandled error');
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Unhandled error',
|
||||
requestId: 'test-request-id',
|
||||
stack: error.stack
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom error status codes', () => {
|
||||
const error = new Error('Service unavailable');
|
||||
error.status = 503;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Service unavailable',
|
||||
requestId: 'test-request-id',
|
||||
stack: error.stack
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Production environment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should return generic error message', () => {
|
||||
const error = new Error('Database connection failed');
|
||||
error.status = 500;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Internal Server Error',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include stack trace in production', () => {
|
||||
const error = new Error('Database error');
|
||||
error.status = 500;
|
||||
error.stack = 'Error: Database error\n at /app/db.js:10:5';
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
const response = res.json.mock.calls[0][0];
|
||||
expect(response).not.toHaveProperty('stack');
|
||||
});
|
||||
|
||||
it('should handle custom status codes in production', () => {
|
||||
const error = new Error('Service down');
|
||||
error.status = 502;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(502);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Internal Server Error',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default status 500 in production', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Internal Server Error',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle error without message', () => {
|
||||
const error = new Error();
|
||||
error.status = 400;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Bad Request',
|
||||
requestId: 'test-request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing request ID', () => {
|
||||
delete req.id;
|
||||
const error = new Error('Test error');
|
||||
error.status = 400;
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Test error',
|
||||
requestId: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error without status property', () => {
|
||||
const error = new Error('No status error');
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it('should not call next() - error handling middleware', () => {
|
||||
const error = new Error('Test error');
|
||||
|
||||
sanitizeError(error, req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module exports', () => {
|
||||
it('should export all required functions', () => {
|
||||
const securityModule = require('../../../middleware/security');
|
||||
|
||||
expect(securityModule).toHaveProperty('enforceHTTPS');
|
||||
expect(securityModule).toHaveProperty('securityHeaders');
|
||||
expect(securityModule).toHaveProperty('addRequestId');
|
||||
expect(securityModule).toHaveProperty('logSecurityEvent');
|
||||
expect(securityModule).toHaveProperty('sanitizeError');
|
||||
});
|
||||
|
||||
it('should export functions with correct types', () => {
|
||||
const securityModule = require('../../../middleware/security');
|
||||
|
||||
expect(typeof securityModule.enforceHTTPS).toBe('function');
|
||||
expect(typeof securityModule.securityHeaders).toBe('function');
|
||||
expect(typeof securityModule.addRequestId).toBe('function');
|
||||
expect(typeof securityModule.logSecurityEvent).toBe('function');
|
||||
expect(typeof securityModule.sanitizeError).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
2061
backend/tests/unit/middleware/validation.test.js
Normal file
2061
backend/tests/unit/middleware/validation.test.js
Normal file
File diff suppressed because it is too large
Load Diff
682
backend/tests/unit/routes/auth.test.js
Normal file
682
backend/tests/unit/routes/auth.test.js
Normal file
@@ -0,0 +1,682 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('google-auth-library');
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
or: 'or'
|
||||
}
|
||||
}));
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findByPk: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock middleware
|
||||
jest.mock('../../../middleware/validation', () => ({
|
||||
sanitizeInput: (req, res, next) => next(),
|
||||
validateRegistration: (req, res, next) => next(),
|
||||
validateLogin: (req, res, next) => next(),
|
||||
validateGoogleAuth: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/csrf', () => ({
|
||||
csrfProtection: (req, res, next) => next(),
|
||||
getCSRFToken: (req, res) => res.json({ csrfToken: 'test-csrf-token' })
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/rateLimiter', () => ({
|
||||
loginLimiter: (req, res, next) => next(),
|
||||
registerLimiter: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
const { User } = require('../../../models');
|
||||
|
||||
// Set up OAuth2Client mock before requiring authRoutes
|
||||
const mockGoogleClient = {
|
||||
verifyIdToken: jest.fn()
|
||||
};
|
||||
OAuth2Client.mockImplementation(() => mockGoogleClient);
|
||||
|
||||
const authRoutes = require('../../../routes/auth');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/auth', authRoutes);
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset environment
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.GOOGLE_CLIENT_ID = 'test-google-client-id';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Reset JWT mock to return different tokens for each call
|
||||
let tokenCallCount = 0;
|
||||
jwt.sign.mockImplementation(() => {
|
||||
tokenCallCount++;
|
||||
return tokenCallCount === 1 ? 'access-token' : 'refresh-token';
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/csrf-token', () => {
|
||||
it('should return CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/auth/csrf-token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('csrfToken');
|
||||
expect(response.body.csrfToken).toBe('test-csrf-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('should register a new user successfully', async () => {
|
||||
User.findOne.mockResolvedValue(null); // No existing user
|
||||
|
||||
const newUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
|
||||
User.create.mockResolvedValue(newUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
phone: '1234567890'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.user).toEqual({
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
// Check that cookies are set
|
||||
expect(response.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('accessToken'),
|
||||
expect.stringContaining('refreshToken')
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject registration with existing email', async () => {
|
||||
User.findOne.mockResolvedValue({ id: 1, email: 'test@example.com' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Registration failed');
|
||||
expect(response.body.details[0].message).toBe('An account with this email already exists');
|
||||
});
|
||||
|
||||
it('should reject registration with existing username', async () => {
|
||||
User.findOne.mockResolvedValue({ id: 1, username: 'testuser' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Registration failed');
|
||||
});
|
||||
|
||||
it('should handle registration errors', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
User.create.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Registration failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('should login user with valid credentials', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
isLocked: jest.fn().mockReturnValue(false),
|
||||
comparePassword: jest.fn().mockResolvedValue(true),
|
||||
resetLoginAttempts: jest.fn().mockResolvedValue()
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual({
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
expect(mockUser.resetLoginAttempts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject login with invalid email', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should reject login with invalid password', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
isLocked: jest.fn().mockReturnValue(false),
|
||||
comparePassword: jest.fn().mockResolvedValue(false),
|
||||
incLoginAttempts: jest.fn().mockResolvedValue()
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid credentials');
|
||||
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject login for locked account', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
isLocked: jest.fn().mockReturnValue(true)
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(423);
|
||||
expect(response.body.error).toContain('Account is temporarily locked');
|
||||
});
|
||||
|
||||
it('should handle login errors', async () => {
|
||||
User.findOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Login failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/google', () => {
|
||||
it('should handle Google OAuth login for new user', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@gmail.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User',
|
||||
picture: 'profile.jpg'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
User.findOne
|
||||
.mockResolvedValueOnce(null) // No existing Google user
|
||||
.mockResolvedValueOnce(null); // No existing email user
|
||||
|
||||
const newUser = {
|
||||
id: 1,
|
||||
username: 'test_gle123',
|
||||
email: 'test@gmail.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
profileImage: 'profile.jpg'
|
||||
};
|
||||
|
||||
User.create.mockResolvedValue(newUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-google-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual(newUser);
|
||||
expect(User.create).toHaveBeenCalledWith({
|
||||
email: 'test@gmail.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
authProvider: 'google',
|
||||
providerId: 'google123',
|
||||
profileImage: 'profile.jpg',
|
||||
username: 'test_gle123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Google OAuth login for existing user', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@gmail.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
const existingUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@gmail.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(existingUser);
|
||||
jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-google-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual(existingUser);
|
||||
});
|
||||
|
||||
it('should reject when email exists with different auth provider', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@example.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
User.findOne
|
||||
.mockResolvedValueOnce(null) // No Google user
|
||||
.mockResolvedValueOnce({ id: 1, email: 'test@example.com' }); // Existing email user
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-google-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.error).toContain('An account with this email already exists');
|
||||
});
|
||||
|
||||
it('should reject missing ID token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('ID token is required');
|
||||
});
|
||||
|
||||
it('should handle expired Google token', async () => {
|
||||
const error = new Error('Token used too late');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'expired-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Google token has expired. Please try again.');
|
||||
});
|
||||
|
||||
it('should handle invalid Google token', async () => {
|
||||
const error = new Error('Invalid token signature');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'invalid-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid Google token. Please try again.');
|
||||
});
|
||||
|
||||
it('should handle malformed Google token', async () => {
|
||||
const error = new Error('Wrong number of segments in token');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'malformed.token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Malformed Google token. Please try again.');
|
||||
});
|
||||
|
||||
it('should handle missing required user information', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@gmail.com',
|
||||
// Missing given_name and family_name
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Required user information not provided by Google');
|
||||
});
|
||||
|
||||
it('should handle unexpected Google auth errors', async () => {
|
||||
const unexpectedError = new Error('Unexpected Google error');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(unexpectedError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'error-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Google authentication failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
it('should refresh access token with valid refresh token', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
|
||||
jwt.verify.mockReturnValue({ id: 1, type: 'refresh' });
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
jwt.sign.mockReturnValue('new-access-token');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=valid-refresh-token']);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual(mockUser);
|
||||
expect(response.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('accessToken=new-access-token')
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject missing refresh token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Refresh token required');
|
||||
});
|
||||
|
||||
it('should reject invalid refresh token', async () => {
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=invalid-token']);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid or expired refresh token');
|
||||
});
|
||||
|
||||
it('should reject non-refresh token type', async () => {
|
||||
jwt.verify.mockReturnValue({ id: 1, type: 'access' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=access-token']);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid refresh token');
|
||||
});
|
||||
|
||||
it('should reject refresh token for non-existent user', async () => {
|
||||
jwt.verify.mockReturnValue({ id: 999, type: 'refresh' });
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=valid-token']);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should logout user and clear cookies', async () => {
|
||||
const response = await request(app)
|
||||
.post('/auth/logout');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
|
||||
// Check that cookies are cleared
|
||||
expect(response.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('accessToken=;'),
|
||||
expect.stringContaining('refreshToken=;')
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security features', () => {
|
||||
it('should set secure cookies in production', async () => {
|
||||
process.env.NODE_ENV = 'prod';
|
||||
|
||||
User.findOne.mockResolvedValue(null);
|
||||
const newUser = { id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' };
|
||||
User.create.mockResolvedValue(newUser);
|
||||
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
// In production, cookies should have secure flag
|
||||
expect(response.headers['set-cookie'][0]).toContain('Secure');
|
||||
});
|
||||
|
||||
it('should generate unique username for Google users', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123456',
|
||||
email: 'test@gmail.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
User.findOne
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
User.create.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'test_123456',
|
||||
email: 'test@gmail.com'
|
||||
});
|
||||
|
||||
jwt.sign.mockReturnValueOnce('token').mockReturnValueOnce('refresh');
|
||||
|
||||
await request(app)
|
||||
.post('/auth/google')
|
||||
.send({ idToken: 'valid-token' });
|
||||
|
||||
expect(User.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'test_123456' // email prefix + last 6 chars of Google ID
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token management', () => {
|
||||
it('should generate both access and refresh tokens on registration', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
|
||||
|
||||
jwt.sign
|
||||
.mockReturnValueOnce('access-token')
|
||||
.mockReturnValueOnce('refresh-token');
|
||||
|
||||
await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
{ id: 1 },
|
||||
'test-secret',
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
{ id: 1, type: 'refresh' },
|
||||
'test-secret',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should set correct cookie options', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
|
||||
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
expect(cookies[0]).toContain('HttpOnly');
|
||||
expect(cookies[0]).toContain('SameSite=Strict');
|
||||
expect(cookies[1]).toContain('HttpOnly');
|
||||
expect(cookies[1]).toContain('SameSite=Strict');
|
||||
});
|
||||
});
|
||||
});
|
||||
823
backend/tests/unit/routes/itemRequests.test.js
Normal file
823
backend/tests/unit/routes/itemRequests.test.js
Normal file
@@ -0,0 +1,823 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const itemRequestsRouter = require('../../../routes/itemRequests');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
ItemRequest: {
|
||||
findAndCountAll: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
ItemRequestResponse: {
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
User: jest.fn(),
|
||||
Item: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
or: Symbol('or'),
|
||||
iLike: Symbol('iLike'),
|
||||
},
|
||||
}));
|
||||
|
||||
const { ItemRequest, ItemRequestResponse, User, Item } = require('../../../models');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/item-requests', itemRequestsRouter);
|
||||
|
||||
// Mock models
|
||||
const mockItemRequestFindAndCountAll = ItemRequest.findAndCountAll;
|
||||
const mockItemRequestFindAll = ItemRequest.findAll;
|
||||
const mockItemRequestFindByPk = ItemRequest.findByPk;
|
||||
const mockItemRequestCreate = ItemRequest.create;
|
||||
const mockItemRequestResponseFindByPk = ItemRequestResponse.findByPk;
|
||||
const mockItemRequestResponseCreate = ItemRequestResponse.create;
|
||||
|
||||
describe('ItemRequests Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should get item requests with default pagination and status', async () => {
|
||||
const mockRequestsData = {
|
||||
count: 25,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Need a Camera',
|
||||
description: 'Looking for a DSLR camera for weekend photography',
|
||||
status: 'open',
|
||||
requesterId: 2,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
requester: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Power Drill Needed',
|
||||
description: 'Need a drill for home improvement project',
|
||||
status: 'open',
|
||||
requesterId: 3,
|
||||
createdAt: '2024-01-14T10:00:00.000Z',
|
||||
requester: {
|
||||
id: 3,
|
||||
username: 'bob_smith',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Smith'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockRequestsData);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
requests: mockRequestsData.rows,
|
||||
totalPages: 2,
|
||||
currentPage: 1,
|
||||
totalRequests: 25
|
||||
});
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: { status: 'open' },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'requester',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
}
|
||||
],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter requests with search query', async () => {
|
||||
const mockSearchResults = {
|
||||
count: 5,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Need a Camera',
|
||||
description: 'Looking for a DSLR camera',
|
||||
status: 'open'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests?search=camera&page=1&limit=10');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'open',
|
||||
[Op.or]: [
|
||||
{ title: { [Op.iLike]: '%camera%' } },
|
||||
{ description: { [Op.iLike]: '%camera%' } }
|
||||
]
|
||||
},
|
||||
include: expect.any(Array),
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom pagination', async () => {
|
||||
const mockData = { count: 50, rows: [] };
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests?page=3&limit=5');
|
||||
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: { status: 'open' },
|
||||
include: expect.any(Array),
|
||||
limit: 5,
|
||||
offset: 10, // (3-1) * 5
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by custom status', async () => {
|
||||
const mockData = { count: 10, rows: [] };
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
||||
|
||||
await request(app)
|
||||
.get('/item-requests?status=fulfilled');
|
||||
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: { status: 'fulfilled' },
|
||||
include: expect.any(Array),
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindAndCountAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /my-requests', () => {
|
||||
it('should get user\'s own requests with responses', async () => {
|
||||
const mockRequests = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'My Camera Request',
|
||||
description: 'Need a camera',
|
||||
status: 'open',
|
||||
requesterId: 1,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
id: 1,
|
||||
message: 'I have a Canon DSLR available',
|
||||
responder: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: {
|
||||
id: 5,
|
||||
name: 'Canon EOS 5D',
|
||||
description: 'Professional DSLR camera'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
mockItemRequestFindAll.mockResolvedValue(mockRequests);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/my-requests');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRequests);
|
||||
expect(mockItemRequestFindAll).toHaveBeenCalledWith({
|
||||
where: { requesterId: 1 },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'requester',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: ItemRequestResponse,
|
||||
as: 'responses',
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'responder',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: Item,
|
||||
as: 'existingItem'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/my-requests');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('should get specific request with responses', async () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
title: 'Camera Request',
|
||||
description: 'Need a DSLR camera',
|
||||
status: 'open',
|
||||
requesterId: 2,
|
||||
requester: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
id: 1,
|
||||
message: 'I have a Canon DSLR',
|
||||
responder: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: null
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRequest);
|
||||
expect(mockItemRequestFindByPk).toHaveBeenCalledWith('1', {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'requester',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: ItemRequestResponse,
|
||||
as: 'responses',
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'responder',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: Item,
|
||||
as: 'existingItem'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('should create a new item request', async () => {
|
||||
const requestData = {
|
||||
title: 'Need a Drill',
|
||||
description: 'Looking for a power drill for weekend project',
|
||||
category: 'tools',
|
||||
budget: 50,
|
||||
location: 'New York'
|
||||
};
|
||||
|
||||
const mockCreatedRequest = {
|
||||
id: 3,
|
||||
...requestData,
|
||||
requesterId: 1,
|
||||
status: 'open'
|
||||
};
|
||||
|
||||
const mockRequestWithRequester = {
|
||||
...mockCreatedRequest,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
};
|
||||
|
||||
mockItemRequestCreate.mockResolvedValue(mockCreatedRequest);
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequestWithRequester);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests')
|
||||
.send(requestData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockRequestWithRequester);
|
||||
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
||||
...requestData,
|
||||
requesterId: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockItemRequestCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests')
|
||||
.send({
|
||||
title: 'Test Request',
|
||||
description: 'Test description'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
title: 'Original Title',
|
||||
requesterId: 1,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
});
|
||||
|
||||
it('should update item request for owner', async () => {
|
||||
const updateData = {
|
||||
title: 'Updated Title',
|
||||
description: 'Updated description'
|
||||
};
|
||||
|
||||
const mockUpdatedRequest = {
|
||||
...mockRequest,
|
||||
...updateData,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.update.mockResolvedValue();
|
||||
mockItemRequestFindByPk
|
||||
.mockResolvedValueOnce(mockRequest)
|
||||
.mockResolvedValueOnce(mockUpdatedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/1')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
title: 'Updated Title',
|
||||
description: 'Updated description',
|
||||
requesterId: 1,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
});
|
||||
expect(mockRequest.update).toHaveBeenCalledWith(updateData);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/999')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
||||
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/1')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/1')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
requesterId: 1,
|
||||
destroy: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
});
|
||||
|
||||
it('should delete item request for owner', async () => {
|
||||
mockRequest.destroy.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockRequest.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
||||
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/responses', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
requesterId: 2,
|
||||
status: 'open',
|
||||
increment: jest.fn()
|
||||
};
|
||||
|
||||
const mockResponseData = {
|
||||
message: 'I have a drill you can borrow',
|
||||
price: 25,
|
||||
existingItemId: 5
|
||||
};
|
||||
|
||||
const mockCreatedResponse = {
|
||||
id: 1,
|
||||
...mockResponseData,
|
||||
itemRequestId: 1,
|
||||
responderId: 1
|
||||
};
|
||||
|
||||
const mockResponseWithDetails = {
|
||||
...mockCreatedResponse,
|
||||
responder: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: {
|
||||
id: 5,
|
||||
name: 'Power Drill',
|
||||
description: 'Cordless power drill'
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
mockItemRequestResponseCreate.mockResolvedValue(mockCreatedResponse);
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponseWithDetails);
|
||||
});
|
||||
|
||||
it('should create a response to item request', async () => {
|
||||
mockRequest.increment.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockResponseWithDetails);
|
||||
expect(mockItemRequestResponseCreate).toHaveBeenCalledWith({
|
||||
...mockResponseData,
|
||||
itemRequestId: '1',
|
||||
responderId: 1
|
||||
});
|
||||
expect(mockRequest.increment).toHaveBeenCalledWith('responseCount');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/999/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should prevent responding to own request', async () => {
|
||||
const ownRequest = { ...mockRequest, requesterId: 1 };
|
||||
mockItemRequestFindByPk.mockResolvedValue(ownRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot respond to your own request' });
|
||||
});
|
||||
|
||||
it('should prevent responding to closed request', async () => {
|
||||
const closedRequest = { ...mockRequest, status: 'fulfilled' };
|
||||
mockItemRequestFindByPk.mockResolvedValue(closedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot respond to closed request' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestResponseCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /responses/:responseId/status', () => {
|
||||
const mockResponse = {
|
||||
id: 1,
|
||||
status: 'pending',
|
||||
itemRequest: {
|
||||
id: 1,
|
||||
requesterId: 1
|
||||
},
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponse);
|
||||
});
|
||||
|
||||
it('should update response status to accepted and fulfill request', async () => {
|
||||
const updatedResponse = {
|
||||
...mockResponse,
|
||||
status: 'accepted',
|
||||
responder: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: null
|
||||
};
|
||||
|
||||
mockResponse.update.mockResolvedValue();
|
||||
mockResponse.itemRequest.update = jest.fn().mockResolvedValue();
|
||||
mockItemRequestResponseFindByPk
|
||||
.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(updatedResponse);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
status: 'accepted',
|
||||
itemRequest: {
|
||||
id: 1,
|
||||
requesterId: 1
|
||||
},
|
||||
responder: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: null
|
||||
});
|
||||
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'accepted' });
|
||||
expect(mockResponse.itemRequest.update).toHaveBeenCalledWith({ status: 'fulfilled' });
|
||||
});
|
||||
|
||||
it('should update response status without fulfilling request', async () => {
|
||||
const updatedResponse = { ...mockResponse, status: 'declined' };
|
||||
mockResponse.update.mockResolvedValue();
|
||||
mockItemRequestResponseFindByPk
|
||||
.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(updatedResponse);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'declined' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'declined' });
|
||||
expect(mockResponse.itemRequest.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent response', async () => {
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/999/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Response not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedResponse = {
|
||||
...mockResponse,
|
||||
itemRequest: { ...mockResponse.itemRequest, requesterId: 2 }
|
||||
};
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(unauthorizedResponse);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Only the requester can update response status' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestResponseFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty search results', async () => {
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests?search=nonexistent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.requests).toEqual([]);
|
||||
expect(response.body.totalRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle zero page calculation', async () => {
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests');
|
||||
|
||||
expect(response.body.totalPages).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle request without optional fields', async () => {
|
||||
const minimalRequest = {
|
||||
title: 'Basic Request',
|
||||
description: 'Simple description'
|
||||
};
|
||||
|
||||
const mockCreated = { id: 1, ...minimalRequest, requesterId: 1 };
|
||||
const mockWithRequester = {
|
||||
...mockCreated,
|
||||
requester: { id: 1, username: 'test' }
|
||||
};
|
||||
|
||||
mockItemRequestCreate.mockResolvedValue(mockCreated);
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockWithRequester);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests')
|
||||
.send(minimalRequest);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
||||
...minimalRequest,
|
||||
requesterId: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1026
backend/tests/unit/routes/items.test.js
Normal file
1026
backend/tests/unit/routes/items.test.js
Normal file
File diff suppressed because it is too large
Load Diff
726
backend/tests/unit/routes/maps.test.js
Normal file
726
backend/tests/unit/routes/maps.test.js
Normal file
@@ -0,0 +1,726 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../services/googleMapsService', () => ({
|
||||
getPlacesAutocomplete: jest.fn(),
|
||||
getPlaceDetails: jest.fn(),
|
||||
geocodeAddress: jest.fn(),
|
||||
isConfigured: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: (req, res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock rate limiter middleware
|
||||
jest.mock('../../../middleware/rateLimiter', () => ({
|
||||
burstProtection: (req, res, next) => next(),
|
||||
placesAutocomplete: (req, res, next) => next(),
|
||||
placeDetails: (req, res, next) => next(),
|
||||
geocoding: (req, res, next) => next()
|
||||
}));
|
||||
|
||||
const googleMapsService = require('../../../services/googleMapsService');
|
||||
const mapsRoutes = require('../../../routes/maps');
|
||||
|
||||
// Set up Express app for testing
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/maps', mapsRoutes);
|
||||
|
||||
describe('Maps Routes', () => {
|
||||
let consoleSpy, consoleErrorSpy, consoleLogSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Input Validation Middleware', () => {
|
||||
it('should trim and validate input length', async () => {
|
||||
const longInput = 'a'.repeat(501);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: longInput });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Input too long' });
|
||||
});
|
||||
|
||||
it('should validate place ID format', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'invalid@place#id!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Invalid place ID format' });
|
||||
});
|
||||
|
||||
it('should validate address length', async () => {
|
||||
const longAddress = 'a'.repeat(501);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: longAddress });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Address too long' });
|
||||
});
|
||||
|
||||
it('should allow valid place ID format', async () => {
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue({
|
||||
result: { name: 'Test Place' }
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc_DEF' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should trim whitespace from inputs', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({
|
||||
predictions: []
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: ' test input ' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'test input',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Middleware', () => {
|
||||
it('should handle API key configuration errors', async () => {
|
||||
const configError = new Error('API key not configured');
|
||||
googleMapsService.getPlacesAutocomplete.mockRejectedValue(configError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Maps service temporarily unavailable',
|
||||
details: 'Configuration issue'
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Maps service error:',
|
||||
'API key not configured'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle quota exceeded errors', async () => {
|
||||
const quotaError = new Error('quota exceeded');
|
||||
googleMapsService.getPlacesAutocomplete.mockRejectedValue(quotaError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Service temporarily unavailable due to high demand',
|
||||
details: 'Please try again later'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generic service errors', async () => {
|
||||
const serviceError = new Error('Network timeout');
|
||||
googleMapsService.getPlacesAutocomplete.mockRejectedValue(serviceError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to process request',
|
||||
details: 'Network timeout'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /places/autocomplete', () => {
|
||||
const mockPredictions = {
|
||||
predictions: [
|
||||
{
|
||||
description: '123 Main St, New York, NY, USA',
|
||||
place_id: 'ChIJ123abc',
|
||||
types: ['street_address']
|
||||
},
|
||||
{
|
||||
description: '456 Oak Ave, New York, NY, USA',
|
||||
place_id: 'ChIJ456def',
|
||||
types: ['street_address']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it('should return autocomplete predictions successfully', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
input: '123 Main',
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'us' },
|
||||
sessionToken: 'session123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPredictions);
|
||||
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'123 Main',
|
||||
{
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'us' },
|
||||
sessionToken: 'session123'
|
||||
}
|
||||
);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete: user=1, query_length=8, results=2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default types when not provided', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'test',
|
||||
{
|
||||
types: ['address'],
|
||||
componentRestrictions: undefined,
|
||||
sessionToken: undefined
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty predictions for short input', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'a' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty predictions for missing input', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'No token provided' });
|
||||
});
|
||||
|
||||
it('should log request with user ID from authenticated user', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should log with user ID
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user=1')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty predictions from service', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'nonexistent place' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete: user=1, query_length=17, results=0'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service response without predictions array', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete: user=1, query_length=4, results=0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /places/details', () => {
|
||||
const mockPlaceDetails = {
|
||||
result: {
|
||||
place_id: 'ChIJ123abc',
|
||||
name: 'Central Park',
|
||||
formatted_address: 'New York, NY 10024, USA',
|
||||
geometry: {
|
||||
location: { lat: 40.785091, lng: -73.968285 }
|
||||
},
|
||||
types: ['park', 'point_of_interest']
|
||||
}
|
||||
};
|
||||
|
||||
it('should return place details successfully', async () => {
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
placeId: 'ChIJ123abc',
|
||||
sessionToken: 'session123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPlaceDetails);
|
||||
|
||||
expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith(
|
||||
'ChIJ123abc',
|
||||
{ sessionToken: 'session123' }
|
||||
);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Place Details: user=1, placeId=ChIJ123abc...'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle place details without session token', async () => {
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith(
|
||||
'ChIJ123abc',
|
||||
{ sessionToken: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for missing place ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Place ID is required' });
|
||||
expect(googleMapsService.getPlaceDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for empty place ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Place ID is required' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.send({ placeId: 'ChIJ123abc' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle very long place IDs in logging', async () => {
|
||||
const longPlaceId = 'ChIJ' + 'a'.repeat(100);
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: longPlaceId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`Place Details: user=1, placeId=${longPlaceId.substring(0, 10)}...`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const serviceError = new Error('Place not found');
|
||||
googleMapsService.getPlaceDetails.mockRejectedValue(serviceError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to process request',
|
||||
details: 'Place not found'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /geocode', () => {
|
||||
const mockGeocodeResults = {
|
||||
results: [
|
||||
{
|
||||
formatted_address: '123 Main St, New York, NY 10001, USA',
|
||||
geometry: {
|
||||
location: { lat: 40.7484405, lng: -73.9856644 }
|
||||
},
|
||||
place_id: 'ChIJ123abc',
|
||||
types: ['street_address']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it('should return geocoding results successfully', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
address: '123 Main St, New York, NY',
|
||||
componentRestrictions: { country: 'US' }
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockGeocodeResults);
|
||||
|
||||
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St, New York, NY',
|
||||
{ componentRestrictions: { country: 'US' } }
|
||||
);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Geocoding: user=1, address_length=25'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle geocoding without component restrictions', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: '123 Main St' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St',
|
||||
{ componentRestrictions: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for missing address', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Address is required' });
|
||||
expect(googleMapsService.geocodeAddress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for empty address', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Address is required' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.send({ address: '123 Main St' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle addresses with special characters', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: '123 Main St, Apt #4B' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St, Apt #4B',
|
||||
{ componentRestrictions: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const serviceError = new Error('Invalid address');
|
||||
googleMapsService.geocodeAddress.mockRejectedValue(serviceError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: 'invalid address' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to process request',
|
||||
details: 'Invalid address'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty geocoding results', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue({ results: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: 'nonexistent address' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ results: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('should return healthy status when service is configured', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'healthy',
|
||||
service: 'Google Maps API Proxy',
|
||||
timestamp: expect.any(String),
|
||||
configuration: {
|
||||
apiKeyConfigured: true
|
||||
}
|
||||
});
|
||||
|
||||
// Verify timestamp is a valid ISO string
|
||||
expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp);
|
||||
});
|
||||
|
||||
it('should return unavailable status when service is not configured', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(false);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toEqual({
|
||||
status: 'unavailable',
|
||||
service: 'Google Maps API Proxy',
|
||||
timestamp: expect.any(String),
|
||||
configuration: {
|
||||
apiKeyConfigured: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not require authentication', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should work without authorization header
|
||||
});
|
||||
|
||||
it('should always return current timestamp', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(true);
|
||||
|
||||
const beforeTime = new Date().toISOString();
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
const afterTime = new Date().toISOString();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(new Date(response.body.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(beforeTime).getTime());
|
||||
expect(new Date(response.body.timestamp).getTime()).toBeLessThanOrEqual(new Date(afterTime).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting Integration', () => {
|
||||
it('should apply burst protection to all endpoints', async () => {
|
||||
// This test verifies that rate limiting middleware is applied
|
||||
// In a real scenario, we'd test actual rate limiting behavior
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The fact that the request succeeded means rate limiting middleware was applied without blocking
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Security', () => {
|
||||
it('should handle null input gracefully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: null });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
});
|
||||
|
||||
it('should handle undefined values in request body', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
input: 'test',
|
||||
types: undefined,
|
||||
componentRestrictions: undefined,
|
||||
sessionToken: undefined
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'test',
|
||||
{
|
||||
types: ['address'], // Should use default
|
||||
componentRestrictions: undefined,
|
||||
sessionToken: undefined
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('invalid json');
|
||||
|
||||
expect(response.status).toBe(400); // Express will handle malformed JSON
|
||||
});
|
||||
|
||||
it('should sanitize input to prevent injection attacks', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const maliciousInput = '<script>alert("xss")</script>';
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: maliciousInput });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Input should be treated as string and passed through
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
maliciousInput,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle concurrent requests to different endpoints', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue({ result: {} });
|
||||
googleMapsService.geocodeAddress.mockResolvedValue({ results: [] });
|
||||
|
||||
const [response1, response2, response3] = await Promise.all([
|
||||
request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test1' }),
|
||||
request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc' }),
|
||||
request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: 'test address' })
|
||||
]);
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
expect(response3.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
657
backend/tests/unit/routes/messages.test.js
Normal file
657
backend/tests/unit/routes/messages.test.js
Normal file
@@ -0,0 +1,657 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const messagesRouter = require('../../../routes/messages');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
Message: {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
or: Symbol('or'),
|
||||
},
|
||||
}));
|
||||
|
||||
const { Message, User } = require('../../../models');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/messages', messagesRouter);
|
||||
|
||||
// Mock models
|
||||
const mockMessageFindAll = Message.findAll;
|
||||
const mockMessageFindOne = Message.findOne;
|
||||
const mockMessageFindByPk = Message.findByPk;
|
||||
const mockMessageCreate = Message.create;
|
||||
const mockMessageCount = Message.count;
|
||||
const mockUserFindByPk = User.findByPk;
|
||||
|
||||
describe('Messages Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should get inbox messages for authenticated user', async () => {
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
senderId: 3,
|
||||
receiverId: 1,
|
||||
subject: 'Another Message',
|
||||
content: 'Hi!',
|
||||
isRead: true,
|
||||
createdAt: '2024-01-14T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 3,
|
||||
firstName: 'Bob',
|
||||
lastName: 'Johnson',
|
||||
profileImage: null
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
mockMessageFindAll.mockResolvedValue(mockMessages);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockMessages);
|
||||
expect(mockMessageFindAll).toHaveBeenCalledWith({
|
||||
where: { receiverId: 1 },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /sent', () => {
|
||||
it('should get sent messages for authenticated user', async () => {
|
||||
const mockSentMessages = [
|
||||
{
|
||||
id: 3,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'My Message',
|
||||
content: 'Hello Jane!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T12:00:00.000Z',
|
||||
receiver: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
mockMessageFindAll.mockResolvedValue(mockSentMessages);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/sent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockSentMessages);
|
||||
expect(mockMessageFindAll).toHaveBeenCalledWith({
|
||||
where: { senderId: 1 },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/sent');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
const mockMessage = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
},
|
||||
receiver: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
],
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||
});
|
||||
|
||||
it('should get message with replies for receiver', async () => {
|
||||
mockMessage.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
},
|
||||
receiver: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||
});
|
||||
|
||||
it('should get message without marking as read for sender', async () => {
|
||||
const senderMessage = { ...mockMessage, senderId: 1, receiverId: 2 };
|
||||
mockMessageFindOne.mockResolvedValue(senderMessage);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
},
|
||||
receiver: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not mark already read message as read', async () => {
|
||||
const readMessage = { ...mockMessage, isRead: true };
|
||||
mockMessageFindOne.mockResolvedValue(readMessage);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent message', async () => {
|
||||
mockMessageFindOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Message not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
const mockReceiver = {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
email: 'jane@example.com'
|
||||
};
|
||||
|
||||
const mockCreatedMessage = {
|
||||
id: 5,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
};
|
||||
|
||||
const mockMessageWithSender = {
|
||||
...mockCreatedMessage,
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||
});
|
||||
|
||||
it('should create a new message', async () => {
|
||||
const messageData = {
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send(messageData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockMessageWithSender);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a reply message with parentMessageId', async () => {
|
||||
const replyData = {
|
||||
receiverId: 2,
|
||||
subject: 'Re: Original Message',
|
||||
content: 'This is a reply',
|
||||
parentMessageId: 1
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send(replyData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'Re: Original Message',
|
||||
content: 'This is a reply',
|
||||
parentMessageId: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent receiver', async () => {
|
||||
mockUserFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 999,
|
||||
subject: 'Test',
|
||||
content: 'Test message'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Receiver not found' });
|
||||
});
|
||||
|
||||
it('should prevent sending messages to self', async () => {
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 1, // Same as sender ID
|
||||
subject: 'Self Message',
|
||||
content: 'Hello self!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot send messages to yourself' });
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockMessageCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 2,
|
||||
subject: 'Test',
|
||||
content: 'Test message'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/read', () => {
|
||||
const mockMessage = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
isRead: false,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||
});
|
||||
|
||||
it('should mark message as read', async () => {
|
||||
const updatedMessage = { ...mockMessage, isRead: true };
|
||||
mockMessage.update.mockResolvedValue(updatedMessage);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/1/read');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
isRead: false
|
||||
});
|
||||
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||
expect(mockMessageFindOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: '1',
|
||||
receiverId: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent message', async () => {
|
||||
mockMessageFindOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/999/read');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Message not found' });
|
||||
});
|
||||
|
||||
it('should return 404 when user is not the receiver', async () => {
|
||||
// Message exists but user is not the receiver (query will return null)
|
||||
mockMessageFindOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/1/read');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Message not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/1/read');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /unread/count', () => {
|
||||
it('should get unread message count for authenticated user', async () => {
|
||||
mockMessageCount.mockResolvedValue(5);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/unread/count');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ count: 5 });
|
||||
expect(mockMessageCount).toHaveBeenCalledWith({
|
||||
where: {
|
||||
receiverId: 1,
|
||||
isRead: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return count of 0 when no unread messages', async () => {
|
||||
mockMessageCount.mockResolvedValue(0);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/unread/count');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageCount.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/unread/count');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message authorization', () => {
|
||||
it('should only find messages where user is sender or receiver', async () => {
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(mockMessageFindOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: '1',
|
||||
[Op.or]: [
|
||||
{ senderId: 1 },
|
||||
{ receiverId: 1 }
|
||||
]
|
||||
},
|
||||
include: expect.any(Array)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty inbox', async () => {
|
||||
mockMessageFindAll.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty sent messages', async () => {
|
||||
mockMessageFindAll.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/sent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle message with no replies', async () => {
|
||||
const messageWithoutReplies = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
replies: [],
|
||||
update: jest.fn()
|
||||
};
|
||||
mockMessageFindOne.mockResolvedValue(messageWithoutReplies);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.replies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields in message creation', async () => {
|
||||
const mockReceiver = { id: 2, firstName: 'Jane', lastName: 'Smith' };
|
||||
const mockCreatedMessage = {
|
||||
id: 6,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: undefined,
|
||||
content: 'Just content',
|
||||
parentMessageId: undefined
|
||||
};
|
||||
const mockMessageWithSender = {
|
||||
...mockCreatedMessage,
|
||||
sender: { id: 1, firstName: 'John', lastName: 'Doe' }
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 2,
|
||||
content: 'Just content'
|
||||
// subject and parentMessageId omitted
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: undefined,
|
||||
content: 'Just content',
|
||||
parentMessageId: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
896
backend/tests/unit/routes/rentals.test.js
Normal file
896
backend/tests/unit/routes/rentals.test.js
Normal file
@@ -0,0 +1,896 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const rentalsRouter = require('../../../routes/rentals');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
Rental: {
|
||||
findAll: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
Item: {
|
||||
findByPk: jest.fn(),
|
||||
},
|
||||
User: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/feeCalculator', () => ({
|
||||
calculateRentalFees: jest.fn(() => ({
|
||||
totalChargedAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
})),
|
||||
formatFeesForDisplay: jest.fn(() => ({
|
||||
baseAmount: '$100.00',
|
||||
platformFee: '$20.00',
|
||||
totalAmount: '$120.00',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/refundService', () => ({
|
||||
getRefundPreview: jest.fn(),
|
||||
processCancellation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/stripeService', () => ({
|
||||
chargePaymentMethod: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Rental, Item, User } = require('../../../models');
|
||||
const FeeCalculator = require('../../../utils/feeCalculator');
|
||||
const RefundService = require('../../../services/refundService');
|
||||
const StripeService = require('../../../services/stripeService');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/rentals', rentalsRouter);
|
||||
|
||||
// Mock models
|
||||
const mockRentalFindAll = Rental.findAll;
|
||||
const mockRentalFindByPk = Rental.findByPk;
|
||||
const mockRentalFindOne = Rental.findOne;
|
||||
const mockRentalCreate = Rental.create;
|
||||
|
||||
describe('Rentals Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /my-rentals', () => {
|
||||
it('should get rentals for authenticated user', async () => {
|
||||
const mockRentals = [
|
||||
{
|
||||
id: 1,
|
||||
renterId: 1,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
renterId: 1,
|
||||
item: { id: 2, name: 'Another Item' },
|
||||
owner: { id: 3, username: 'owner2', firstName: 'Jane', lastName: 'Smith' },
|
||||
},
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockRentals);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-rentals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRentals);
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: { renterId: 1 },
|
||||
include: [
|
||||
{ model: Item, as: 'item' },
|
||||
{
|
||||
model: User,
|
||||
as: 'owner',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName'],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-rentals');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to fetch rentals' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /my-listings', () => {
|
||||
it('should get listings for authenticated user', async () => {
|
||||
const mockListings = [
|
||||
{
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
item: { id: 1, name: 'My Item' },
|
||||
renter: { id: 2, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
},
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockListings);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-listings');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockListings);
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: { ownerId: 1 },
|
||||
include: [
|
||||
{ model: Item, as: 'item' },
|
||||
{
|
||||
model: User,
|
||||
as: 'renter',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName'],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-listings');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to fetch listings' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
renterId: 1,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
};
|
||||
|
||||
it('should get rental by ID for authorized user (renter)', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRental);
|
||||
});
|
||||
|
||||
it('should get rental by ID for authorized user (owner)', async () => {
|
||||
const ownerRental = { ...mockRental, ownerId: 1, renterId: 2 };
|
||||
mockRentalFindByPk.mockResolvedValue(ownerRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(ownerRental);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
|
||||
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized to view this rental' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to fetch rental' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
const mockItem = {
|
||||
id: 1,
|
||||
name: 'Test Item',
|
||||
ownerId: 2,
|
||||
availability: true,
|
||||
pricePerHour: 10,
|
||||
pricePerDay: 50,
|
||||
};
|
||||
|
||||
const mockCreatedRental = {
|
||||
id: 1,
|
||||
itemId: 1,
|
||||
renterId: 1,
|
||||
ownerId: 2,
|
||||
totalAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
const mockRentalWithDetails = {
|
||||
...mockCreatedRental,
|
||||
item: mockItem,
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
};
|
||||
|
||||
const rentalData = {
|
||||
itemId: 1,
|
||||
startDateTime: '2024-01-15T10:00:00.000Z',
|
||||
endDateTime: '2024-01-15T18:00:00.000Z',
|
||||
deliveryMethod: 'pickup',
|
||||
deliveryAddress: null,
|
||||
notes: 'Test rental',
|
||||
stripePaymentMethodId: 'pm_test123',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Item.findByPk.mockResolvedValue(mockItem);
|
||||
mockRentalFindOne.mockResolvedValue(null); // No overlapping rentals
|
||||
mockRentalCreate.mockResolvedValue(mockCreatedRental);
|
||||
mockRentalFindByPk.mockResolvedValue(mockRentalWithDetails);
|
||||
});
|
||||
|
||||
it('should create a new rental with hourly pricing', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockRentalWithDetails);
|
||||
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(80); // 8 hours * 10/hour
|
||||
});
|
||||
|
||||
it('should create a new rental with daily pricing', async () => {
|
||||
const dailyRentalData = {
|
||||
...rentalData,
|
||||
endDateTime: '2024-01-17T18:00:00.000Z', // 3 days
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(dailyRentalData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(150); // 3 days * 50/day
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
Item.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item not found' });
|
||||
});
|
||||
|
||||
it('should return 400 for unavailable item', async () => {
|
||||
Item.findByPk.mockResolvedValue({ ...mockItem, availability: false });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Item is not available' });
|
||||
});
|
||||
|
||||
it('should return 400 for overlapping rental', async () => {
|
||||
mockRentalFindOne.mockResolvedValue({ id: 999 }); // Overlapping rental exists
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Item is already booked for these dates' });
|
||||
});
|
||||
|
||||
it('should return 400 when payment method is missing', async () => {
|
||||
const dataWithoutPayment = { ...rentalData };
|
||||
delete dataWithoutPayment.stripePaymentMethodId;
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(dataWithoutPayment);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Payment method is required' });
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockRentalCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to create rental' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/status', () => {
|
||||
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',
|
||||
stripeCustomerId: 'cus_test123'
|
||||
},
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should update rental status to confirmed without payment processing', async () => {
|
||||
const nonPendingRental = { ...mockRental, status: 'active' };
|
||||
mockRentalFindByPk.mockResolvedValueOnce(nonPendingRental);
|
||||
|
||||
const updatedRental = { ...nonPendingRental, status: 'confirmed' };
|
||||
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(nonPendingRental.update).toHaveBeenCalledWith({ status: 'confirmed' });
|
||||
});
|
||||
|
||||
it('should process payment when owner approves pending rental', async () => {
|
||||
// Use the original mockRental (status: 'pending') for this test
|
||||
mockRentalFindByPk.mockResolvedValueOnce(mockRental);
|
||||
|
||||
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||
paymentIntentId: 'pi_test123',
|
||||
});
|
||||
|
||||
const updatedRental = {
|
||||
...mockRental,
|
||||
status: 'confirmed',
|
||||
paymentStatus: 'paid',
|
||||
stripePaymentIntentId: 'pi_test123'
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(StripeService.chargePaymentMethod).toHaveBeenCalledWith(
|
||||
'pm_test123',
|
||||
120,
|
||||
'cus_test123',
|
||||
expect.objectContaining({
|
||||
rentalId: 1,
|
||||
itemName: 'Test Item',
|
||||
})
|
||||
);
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
status: 'confirmed',
|
||||
paymentStatus: 'paid',
|
||||
stripePaymentIntentId: 'pi_test123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 when renter has no Stripe customer ID', async () => {
|
||||
const rentalWithoutStripeCustomer = {
|
||||
...mockRental,
|
||||
renter: { ...mockRental.renter, stripeCustomerId: null }
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValue(rentalWithoutStripeCustomer);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Renter does not have a Stripe customer account'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle payment failure during approval', async () => {
|
||||
StripeService.chargePaymentMethod.mockRejectedValue(
|
||||
new Error('Payment failed')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Payment failed during approval',
|
||||
details: 'Payment failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
|
||||
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized to update this rental' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to update rental status' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/review-renter', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'completed',
|
||||
renterReviewSubmittedAt: null,
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should allow owner to review renter', async () => {
|
||||
const reviewData = {
|
||||
rating: 5,
|
||||
review: 'Great renter!',
|
||||
privateMessage: 'Thanks for taking care of my item',
|
||||
};
|
||||
|
||||
mockRental.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send(reviewData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
renterRating: 5,
|
||||
renterReview: 'Great renter!',
|
||||
renterReviewSubmittedAt: expect.any(Date),
|
||||
renterPrivateMessage: 'Thanks for taking care of my item',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for non-owner', async () => {
|
||||
const nonOwnerRental = { ...mockRental, ownerId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Only owners can review renters' });
|
||||
});
|
||||
|
||||
it('should return 400 for non-completed rental', async () => {
|
||||
const activeRental = { ...mockRental, status: 'active' };
|
||||
mockRentalFindByPk.mockResolvedValue(activeRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Can only review completed rentals' });
|
||||
});
|
||||
|
||||
it('should return 400 if review already submitted', async () => {
|
||||
const reviewedRental = {
|
||||
...mockRental,
|
||||
renterReviewSubmittedAt: new Date()
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValue(reviewedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Renter review already submitted' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to submit review' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/review-item', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
renterId: 1,
|
||||
status: 'completed',
|
||||
itemReviewSubmittedAt: null,
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should allow renter to review item', async () => {
|
||||
const reviewData = {
|
||||
rating: 4,
|
||||
review: 'Good item!',
|
||||
privateMessage: 'Item was as described',
|
||||
};
|
||||
|
||||
mockRental.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-item')
|
||||
.send(reviewData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
itemRating: 4,
|
||||
itemReview: 'Good item!',
|
||||
itemReviewSubmittedAt: expect.any(Date),
|
||||
itemPrivateMessage: 'Item was as described',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 403 for non-renter', async () => {
|
||||
const nonRenterRental = { ...mockRental, renterId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-item')
|
||||
.send({ rating: 4, review: 'Good!' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Only renters can review items' });
|
||||
});
|
||||
|
||||
it('should return 400 if review already submitted', async () => {
|
||||
const reviewedRental = {
|
||||
...mockRental,
|
||||
itemReviewSubmittedAt: new Date()
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValue(reviewedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-item')
|
||||
.send({ rating: 4, review: 'Good!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Item review already submitted' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/mark-completed', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'active',
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should allow owner to mark rental as completed', async () => {
|
||||
const completedRental = { ...mockRental, status: 'completed' };
|
||||
mockRentalFindByPk
|
||||
.mockResolvedValueOnce(mockRental)
|
||||
.mockResolvedValueOnce(completedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-completed');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRental.update).toHaveBeenCalledWith({ status: 'completed' });
|
||||
});
|
||||
|
||||
it('should return 403 for non-owner', async () => {
|
||||
const nonOwnerRental = { ...mockRental, ownerId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-completed');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Only owners can mark rentals as completed'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid status', async () => {
|
||||
const pendingRental = { ...mockRental, status: 'pending' };
|
||||
mockRentalFindByPk.mockResolvedValue(pendingRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-completed');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Can only mark active or confirmed rentals as completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /calculate-fees', () => {
|
||||
it('should calculate fees for given amount', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/calculate-fees')
|
||||
.send({ totalAmount: 100 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
fees: {
|
||||
totalChargedAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
},
|
||||
display: {
|
||||
baseAmount: '$100.00',
|
||||
platformFee: '$20.00',
|
||||
totalAmount: '$120.00',
|
||||
},
|
||||
});
|
||||
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(100);
|
||||
expect(FeeCalculator.formatFeesForDisplay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid amount', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/calculate-fees')
|
||||
.send({ totalAmount: 0 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Valid base amount is required' });
|
||||
});
|
||||
|
||||
it('should handle calculation errors', async () => {
|
||||
FeeCalculator.calculateRentalFees.mockImplementation(() => {
|
||||
throw new Error('Calculation error');
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/calculate-fees')
|
||||
.send({ totalAmount: 100 });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to calculate fees' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /earnings/status', () => {
|
||||
it('should get earnings status for owner', async () => {
|
||||
const mockEarnings = [
|
||||
{
|
||||
id: 1,
|
||||
totalAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
payoutStatus: 'completed',
|
||||
payoutProcessedAt: '2024-01-15T10:00:00.000Z',
|
||||
stripeTransferId: 'tr_test123',
|
||||
item: { name: 'Test Item' },
|
||||
},
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockEarnings);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/earnings/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockEarnings);
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: {
|
||||
ownerId: 1,
|
||||
status: 'completed',
|
||||
},
|
||||
attributes: [
|
||||
'id',
|
||||
'totalAmount',
|
||||
'platformFee',
|
||||
'payoutAmount',
|
||||
'payoutStatus',
|
||||
'payoutProcessedAt',
|
||||
'stripeTransferId',
|
||||
],
|
||||
include: [{ model: Item, as: 'item', attributes: ['name'] }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/earnings/status');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/refund-preview', () => {
|
||||
it('should get refund preview', async () => {
|
||||
const mockPreview = {
|
||||
refundAmount: 80,
|
||||
refundPercentage: 80,
|
||||
reason: 'Cancelled more than 24 hours before start',
|
||||
};
|
||||
|
||||
RefundService.getRefundPreview.mockResolvedValue(mockPreview);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/refund-preview');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPreview);
|
||||
expect(RefundService.getRefundPreview).toHaveBeenCalledWith('1', 1);
|
||||
});
|
||||
|
||||
it('should handle refund service errors', async () => {
|
||||
RefundService.getRefundPreview.mockRejectedValue(
|
||||
new Error('Rental not found')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/refund-preview');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/cancel', () => {
|
||||
it('should cancel rental with refund', async () => {
|
||||
const mockResult = {
|
||||
rental: {
|
||||
id: 1,
|
||||
status: 'cancelled',
|
||||
},
|
||||
refund: {
|
||||
amount: 80,
|
||||
stripeRefundId: 'rf_test123',
|
||||
},
|
||||
};
|
||||
|
||||
const mockUpdatedRental = {
|
||||
id: 1,
|
||||
status: 'cancelled',
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
};
|
||||
|
||||
RefundService.processCancellation.mockResolvedValue(mockResult);
|
||||
mockRentalFindByPk.mockResolvedValue(mockUpdatedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/cancel')
|
||||
.send({ reason: 'Change of plans' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
rental: mockUpdatedRental,
|
||||
refund: mockResult.refund,
|
||||
});
|
||||
expect(RefundService.processCancellation).toHaveBeenCalledWith(
|
||||
'1',
|
||||
1,
|
||||
'Change of plans'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle cancellation errors', async () => {
|
||||
RefundService.processCancellation.mockRejectedValue(
|
||||
new Error('Cannot cancel completed rental')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/cancel')
|
||||
.send({ reason: 'Change of plans' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot cancel completed rental' });
|
||||
});
|
||||
});
|
||||
});
|
||||
805
backend/tests/unit/routes/stripe.test.js
Normal file
805
backend/tests/unit/routes/stripe.test.js
Normal file
@@ -0,0 +1,805 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findOne: jest.fn()
|
||||
},
|
||||
Item: {}
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/stripeService', () => ({
|
||||
getCheckoutSession: jest.fn(),
|
||||
createConnectedAccount: jest.fn(),
|
||||
createAccountLink: jest.fn(),
|
||||
getAccountStatus: jest.fn(),
|
||||
createCustomer: jest.fn(),
|
||||
createSetupCheckoutSession: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: (req, res, next) => {
|
||||
// Mock authenticated user
|
||||
if (req.headers.authorization) {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const { User } = require('../../../models');
|
||||
const StripeService = require('../../../services/stripeService');
|
||||
const stripeRoutes = require('../../../routes/stripe');
|
||||
|
||||
// Set up Express app for testing
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/stripe', stripeRoutes);
|
||||
|
||||
describe('Stripe Routes', () => {
|
||||
let consoleSpy, consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('GET /checkout-session/:sessionId', () => {
|
||||
it('should retrieve checkout session successfully', async () => {
|
||||
const mockSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_details: {
|
||||
email: 'test@example.com'
|
||||
},
|
||||
setup_intent: {
|
||||
id: 'seti_123456789',
|
||||
status: 'succeeded'
|
||||
},
|
||||
metadata: {
|
||||
userId: '1'
|
||||
}
|
||||
};
|
||||
|
||||
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/cs_123456789');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_email: 'test@example.com',
|
||||
setup_intent: {
|
||||
id: 'seti_123456789',
|
||||
status: 'succeeded'
|
||||
},
|
||||
metadata: {
|
||||
userId: '1'
|
||||
}
|
||||
});
|
||||
|
||||
expect(StripeService.getCheckoutSession).toHaveBeenCalledWith('cs_123456789');
|
||||
});
|
||||
|
||||
it('should handle missing customer_details gracefully', async () => {
|
||||
const mockSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_details: null,
|
||||
setup_intent: null,
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/cs_123456789');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_email: undefined,
|
||||
setup_intent: null,
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle checkout session retrieval errors', async () => {
|
||||
const error = new Error('Session not found');
|
||||
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/invalid_session');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Session not found' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving checkout session:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing session ID', async () => {
|
||||
const error = new Error('Invalid session ID');
|
||||
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /accounts', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
stripeConnectedAccountId: null,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser.update.mockReset();
|
||||
mockUser.stripeConnectedAccountId = null;
|
||||
});
|
||||
|
||||
it('should create connected account successfully', async () => {
|
||||
const mockAccount = {
|
||||
id: 'acct_123456789',
|
||||
email: 'test@example.com',
|
||||
country: 'US'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
stripeConnectedAccountId: 'acct_123456789',
|
||||
success: true
|
||||
});
|
||||
|
||||
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||
expect(StripeService.createConnectedAccount).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
country: 'US'
|
||||
});
|
||||
expect(mockUser.update).toHaveBeenCalledWith({
|
||||
stripeConnectedAccountId: 'acct_123456789'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'User not found' });
|
||||
expect(StripeService.createConnectedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if user already has connected account', async () => {
|
||||
const userWithAccount = {
|
||||
...mockUser,
|
||||
stripeConnectedAccountId: 'acct_existing'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithAccount);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'User already has a connected account' });
|
||||
expect(StripeService.createConnectedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'No token provided' });
|
||||
});
|
||||
|
||||
it('should handle Stripe account creation errors', async () => {
|
||||
const error = new Error('Invalid email address');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Invalid email address' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating connected account:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const mockAccount = { id: 'acct_123456789' };
|
||||
const dbError = new Error('Database update failed');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||
mockUser.update.mockRejectedValue(dbError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database update failed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /account-links', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: 'acct_123456789'
|
||||
};
|
||||
|
||||
it('should create account link successfully', async () => {
|
||||
const mockAccountLink = {
|
||||
url: 'https://connect.stripe.com/setup/e/acct_123456789',
|
||||
expires_at: Date.now() + 3600
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createAccountLink.mockResolvedValue(mockAccountLink);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
url: mockAccountLink.url,
|
||||
expiresAt: mockAccountLink.expires_at
|
||||
});
|
||||
|
||||
expect(StripeService.createAccountLink).toHaveBeenCalledWith(
|
||||
'acct_123456789',
|
||||
'http://localhost:3000/refresh',
|
||||
'http://localhost:3000/return'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if no connected account found', async () => {
|
||||
const userWithoutAccount = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: null
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithoutAccount);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
expect(StripeService.createAccountLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
});
|
||||
|
||||
it('should validate required URLs', async () => {
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh'
|
||||
// Missing returnUrl
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'refreshUrl and returnUrl are required' });
|
||||
expect(StripeService.createAccountLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate both URLs are provided', async () => {
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
// Missing refreshUrl
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'refreshUrl and returnUrl are required' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle Stripe account link creation errors', async () => {
|
||||
const error = new Error('Account not found');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createAccountLink.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Account not found' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating account link:',
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /account-status', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: 'acct_123456789'
|
||||
};
|
||||
|
||||
it('should get account status successfully', async () => {
|
||||
const mockAccountStatus = {
|
||||
id: 'acct_123456789',
|
||||
details_submitted: true,
|
||||
payouts_enabled: true,
|
||||
capabilities: {
|
||||
transfers: { status: 'active' }
|
||||
},
|
||||
requirements: {
|
||||
pending_verification: [],
|
||||
currently_due: [],
|
||||
past_due: []
|
||||
}
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.getAccountStatus.mockResolvedValue(mockAccountStatus);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
accountId: 'acct_123456789',
|
||||
detailsSubmitted: true,
|
||||
payoutsEnabled: true,
|
||||
capabilities: {
|
||||
transfers: { status: 'active' }
|
||||
},
|
||||
requirements: {
|
||||
pending_verification: [],
|
||||
currently_due: [],
|
||||
past_due: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(StripeService.getAccountStatus).toHaveBeenCalledWith('acct_123456789');
|
||||
});
|
||||
|
||||
it('should return error if no connected account found', async () => {
|
||||
const userWithoutAccount = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: null
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithoutAccount);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
expect(StripeService.getAccountStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle Stripe account status retrieval errors', async () => {
|
||||
const error = new Error('Account not found');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.getAccountStatus.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Account not found' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error getting account status:',
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /create-setup-checkout-session', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
stripeCustomerId: null,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser.update.mockReset();
|
||||
mockUser.stripeCustomerId = null;
|
||||
});
|
||||
|
||||
it('should create setup checkout session for new customer', async () => {
|
||||
const mockCustomer = {
|
||||
id: 'cus_123456789',
|
||||
email: 'test@example.com'
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
const rentalData = {
|
||||
itemId: '123',
|
||||
startDate: '2023-12-01',
|
||||
endDate: '2023-12-03'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ rentalData });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
clientSecret: 'cs_123456789_secret_test',
|
||||
sessionId: 'cs_123456789'
|
||||
});
|
||||
|
||||
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||
expect(StripeService.createCustomer).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
name: 'John Doe',
|
||||
metadata: {
|
||||
userId: '1'
|
||||
}
|
||||
});
|
||||
expect(mockUser.update).toHaveBeenCalledWith({ stripeCustomerId: 'cus_123456789' });
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {
|
||||
rentalData: JSON.stringify(rentalData)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use existing customer ID if available', async () => {
|
||||
const userWithCustomer = {
|
||||
...mockUser,
|
||||
stripeCustomerId: 'cus_existing123'
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
clientSecret: 'cs_123456789_secret_test',
|
||||
sessionId: 'cs_123456789'
|
||||
});
|
||||
|
||||
expect(StripeService.createCustomer).not.toHaveBeenCalled();
|
||||
expect(userWithCustomer.update).not.toHaveBeenCalled();
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_existing123',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle session without rental data', async () => {
|
||||
const mockCustomer = {
|
||||
id: 'cus_123456789'
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'User not found' });
|
||||
expect(StripeService.createCustomer).not.toHaveBeenCalled();
|
||||
expect(StripeService.createSetupCheckoutSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle customer creation errors', async () => {
|
||||
const error = new Error('Invalid email address');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Invalid email address' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating setup checkout session:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const mockCustomer = { id: 'cus_123456789' };
|
||||
const dbError = new Error('Database update failed');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
mockUser.update.mockRejectedValue(dbError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database update failed' });
|
||||
});
|
||||
|
||||
it('should handle session creation errors', async () => {
|
||||
const mockCustomer = { id: 'cus_123456789' };
|
||||
const sessionError = new Error('Session creation failed');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
StripeService.createSetupCheckoutSession.mockRejectedValue(sessionError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Session creation failed' });
|
||||
});
|
||||
|
||||
it('should handle complex rental data', async () => {
|
||||
const mockCustomer = { id: 'cus_123456789' };
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
const complexRentalData = {
|
||||
itemId: '123',
|
||||
startDate: '2023-12-01',
|
||||
endDate: '2023-12-03',
|
||||
totalAmount: 150.00,
|
||||
additionalServices: ['cleaning', 'delivery'],
|
||||
notes: 'Special instructions'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ rentalData: complexRentalData });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {
|
||||
rentalData: JSON.stringify(complexRentalData)
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling and edge cases', () => {
|
||||
it('should handle malformed JSON in rental data', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
stripeCustomerId: 'cus_123456789'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
// This should work fine as Express will parse valid JSON
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"rentalData":{"itemId":"123"}}');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle very large session IDs', async () => {
|
||||
const longSessionId = 'cs_' + 'a'.repeat(100);
|
||||
const error = new Error('Session ID too long');
|
||||
|
||||
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/stripe/checkout-session/${longSessionId}`);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Session ID too long' });
|
||||
});
|
||||
|
||||
it('should handle concurrent requests for same user', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
stripeConnectedAccountId: null,
|
||||
update: jest.fn().mockResolvedValue({})
|
||||
};
|
||||
|
||||
const mockAccount = { id: 'acct_123456789' };
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||
|
||||
// Simulate concurrent requests
|
||||
const [response1, response2] = await Promise.all([
|
||||
request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token'),
|
||||
request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
]);
|
||||
|
||||
// Both should succeed (in this test scenario)
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
658
backend/tests/unit/routes/users.test.js
Normal file
658
backend/tests/unit/routes/users.test.js
Normal file
@@ -0,0 +1,658 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const usersRouter = require('../../../routes/users');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
UserAddress: {
|
||||
findAll: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = {
|
||||
id: 1,
|
||||
update: jest.fn()
|
||||
};
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/upload', () => ({
|
||||
uploadProfileImage: jest.fn((req, res, callback) => {
|
||||
// Mock successful upload
|
||||
req.file = {
|
||||
filename: 'test-profile.jpg'
|
||||
};
|
||||
callback(null);
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('path');
|
||||
const { User, UserAddress } = require('../../../models');
|
||||
const { uploadProfileImage } = require('../../../middleware/upload');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/users', usersRouter);
|
||||
|
||||
// Mock models
|
||||
const mockUserFindByPk = User.findByPk;
|
||||
const mockUserUpdate = User.update;
|
||||
const mockUserAddressFindAll = UserAddress.findAll;
|
||||
const mockUserAddressFindByPk = UserAddress.findByPk;
|
||||
const mockUserAddressCreate = UserAddress.create;
|
||||
|
||||
describe('Users Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /profile', () => {
|
||||
it('should get user profile for authenticated user', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '555-1234',
|
||||
profileImage: 'profile.jpg',
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/profile');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/profile');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /addresses', () => {
|
||||
it('should get user addresses', async () => {
|
||||
const mockAddresses = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
city: 'New York',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
address1: '456 Oak Ave',
|
||||
city: 'Boston',
|
||||
isPrimary: false,
|
||||
},
|
||||
];
|
||||
|
||||
mockUserAddressFindAll.mockResolvedValue(mockAddresses);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/addresses');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAddresses);
|
||||
expect(mockUserAddressFindAll).toHaveBeenCalledWith({
|
||||
where: { userId: 1 },
|
||||
order: [['isPrimary', 'DESC'], ['createdAt', 'ASC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserAddressFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/addresses');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /addresses', () => {
|
||||
it('should create a new address', async () => {
|
||||
const addressData = {
|
||||
address1: '789 Pine St',
|
||||
address2: 'Apt 4B',
|
||||
city: 'Chicago',
|
||||
state: 'IL',
|
||||
zipCode: '60601',
|
||||
country: 'USA',
|
||||
isPrimary: false,
|
||||
};
|
||||
|
||||
const mockCreatedAddress = {
|
||||
id: 3,
|
||||
...addressData,
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
mockUserAddressCreate.mockResolvedValue(mockCreatedAddress);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/addresses')
|
||||
.send(addressData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockCreatedAddress);
|
||||
expect(mockUserAddressCreate).toHaveBeenCalledWith({
|
||||
...addressData,
|
||||
userId: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockUserAddressCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/addresses')
|
||||
.send({
|
||||
address1: '789 Pine St',
|
||||
city: 'Chicago',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /addresses/:id', () => {
|
||||
const mockAddress = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
city: 'New York',
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||
});
|
||||
|
||||
it('should update user address', async () => {
|
||||
const updateData = {
|
||||
address1: '123 Updated St',
|
||||
city: 'Updated City',
|
||||
};
|
||||
|
||||
mockAddress.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/1')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
city: 'New York',
|
||||
});
|
||||
expect(mockAddress.update).toHaveBeenCalledWith(updateData);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent address', async () => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/999')
|
||||
.send({ address1: 'Updated St' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Address not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/1')
|
||||
.send({ address1: 'Updated St' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/1')
|
||||
.send({ address1: 'Updated St' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /addresses/:id', () => {
|
||||
const mockAddress = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||
});
|
||||
|
||||
it('should delete user address', async () => {
|
||||
mockAddress.destroy.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockAddress.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent address', async () => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Address not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/1');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /availability', () => {
|
||||
it('should get user availability settings', async () => {
|
||||
const mockUser = {
|
||||
defaultAvailableAfter: '09:00',
|
||||
defaultAvailableBefore: '17:00',
|
||||
defaultSpecifyTimesPerDay: true,
|
||||
defaultWeeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/availability');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
generalAvailableAfter: '09:00',
|
||||
generalAvailableBefore: '17:00',
|
||||
specifyTimesPerDay: true,
|
||||
weeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
||||
});
|
||||
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/availability');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /availability', () => {
|
||||
it('should update user availability settings', async () => {
|
||||
const availabilityData = {
|
||||
generalAvailableAfter: '08:00',
|
||||
generalAvailableBefore: '18:00',
|
||||
specifyTimesPerDay: false,
|
||||
weeklyTimes: { monday: '08:00-18:00' },
|
||||
};
|
||||
|
||||
mockUserUpdate.mockResolvedValue([1]);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/availability')
|
||||
.send(availabilityData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Availability updated successfully' });
|
||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
||||
defaultAvailableAfter: '08:00',
|
||||
defaultAvailableBefore: '18:00',
|
||||
defaultSpecifyTimesPerDay: false,
|
||||
defaultWeeklyTimes: { monday: '08:00-18:00' },
|
||||
}, {
|
||||
where: { id: 1 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserUpdate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/availability')
|
||||
.send({
|
||||
generalAvailableAfter: '08:00',
|
||||
generalAvailableBefore: '18:00',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('should get public user profile by ID', async () => {
|
||||
const mockUser = {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
username: 'janesmith',
|
||||
profileImage: 'jane.jpg',
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/2');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockUserFindByPk).toHaveBeenCalledWith('2', {
|
||||
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
mockUserFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'User not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/2');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /profile', () => {
|
||||
const mockUpdatedUser = {
|
||||
id: 1,
|
||||
firstName: 'Updated',
|
||||
lastName: 'User',
|
||||
email: 'updated@example.com',
|
||||
phone: '555-9999',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserFindByPk.mockResolvedValue(mockUpdatedUser);
|
||||
});
|
||||
|
||||
it('should update user profile', async () => {
|
||||
const profileData = {
|
||||
firstName: 'Updated',
|
||||
lastName: 'User',
|
||||
email: 'updated@example.com',
|
||||
phone: '555-9999',
|
||||
address1: '123 New St',
|
||||
city: 'New City',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send(profileData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedUser);
|
||||
});
|
||||
|
||||
it('should exclude empty email from update', async () => {
|
||||
const profileData = {
|
||||
firstName: 'Updated',
|
||||
lastName: 'User',
|
||||
email: '',
|
||||
phone: '555-9999',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send(profileData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Verify email was not included in the update call
|
||||
// (This would need to check the actual update call if we spy on req.user.update)
|
||||
});
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
const mockValidationError = new Error('Validation error');
|
||||
mockValidationError.errors = [
|
||||
{ path: 'email', message: 'Invalid email format' }
|
||||
];
|
||||
|
||||
// Mock req.user.update to throw validation error
|
||||
const { authenticateToken } = require('../../../middleware/auth');
|
||||
authenticateToken.mockImplementation((req, res, next) => {
|
||||
req.user = {
|
||||
id: 1,
|
||||
update: jest.fn().mockRejectedValue(mockValidationError)
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send({
|
||||
firstName: 'Test',
|
||||
email: 'invalid-email',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Validation error',
|
||||
details: [{ field: 'email', message: 'Invalid email format' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle general database errors', async () => {
|
||||
// Reset the authenticateToken mock to use default user
|
||||
const { authenticateToken } = require('../../../middleware/auth');
|
||||
authenticateToken.mockImplementation((req, res, next) => {
|
||||
req.user = {
|
||||
id: 1,
|
||||
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send({
|
||||
firstName: 'Test',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /profile/image', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
profileImage: 'old-image.jpg',
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
it('should upload profile image successfully', async () => {
|
||||
mockUser.update.mockResolvedValue();
|
||||
fs.unlink.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Profile image uploaded successfully',
|
||||
filename: 'test-profile.jpg',
|
||||
imageUrl: '/uploads/profiles/test-profile.jpg'
|
||||
});
|
||||
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
|
||||
expect(mockUser.update).toHaveBeenCalledWith({
|
||||
profileImage: 'test-profile.jpg'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle upload errors', async () => {
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
callback(new Error('File too large'));
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'File too large' });
|
||||
});
|
||||
|
||||
it('should handle missing file', async () => {
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = null;
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No file uploaded' });
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
// Mock upload to succeed but database update to fail
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = { filename: 'test-profile.jpg' };
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const userWithError = {
|
||||
...mockUser,
|
||||
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
||||
};
|
||||
mockUserFindByPk.mockResolvedValue(userWithError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to update profile image' });
|
||||
});
|
||||
|
||||
it('should handle case when user has no existing profile image', async () => {
|
||||
// Mock upload to succeed
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = { filename: 'test-profile.jpg' };
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const userWithoutImage = {
|
||||
id: 1,
|
||||
profileImage: null,
|
||||
update: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mockUserFindByPk.mockResolvedValue(userWithoutImage);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fs.unlink).not.toHaveBeenCalled(); // No old image to delete
|
||||
});
|
||||
|
||||
it('should continue if old image deletion fails', async () => {
|
||||
// Mock upload to succeed
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = { filename: 'test-profile.jpg' };
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const userWithImage = {
|
||||
id: 1,
|
||||
profileImage: 'old-image.jpg',
|
||||
update: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mockUserFindByPk.mockResolvedValue(userWithImage);
|
||||
fs.unlink.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Profile image uploaded successfully',
|
||||
filename: 'test-profile.jpg',
|
||||
imageUrl: '/uploads/profiles/test-profile.jpg'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
940
backend/tests/unit/services/googleMapsService.test.js
Normal file
940
backend/tests/unit/services/googleMapsService.test.js
Normal file
@@ -0,0 +1,940 @@
|
||||
// Mock the Google Maps client
|
||||
const mockPlaceAutocomplete = jest.fn();
|
||||
const mockPlaceDetails = jest.fn();
|
||||
const mockGeocode = jest.fn();
|
||||
|
||||
jest.mock('@googlemaps/google-maps-services-js', () => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
placeAutocomplete: mockPlaceAutocomplete,
|
||||
placeDetails: mockPlaceDetails,
|
||||
geocode: mockGeocode
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('GoogleMapsService', () => {
|
||||
let service;
|
||||
let consoleSpy, consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
// Reset environment
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
|
||||
// Clear module cache to get fresh instance
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with API key and log success', () => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
|
||||
service = require('../../../services/googleMapsService');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('✅ Google Maps service initialized');
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('should log error when API key is not configured', () => {
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
|
||||
service = require('../../../services/googleMapsService');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Google Maps API key not configured in environment variables');
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize Google Maps Client', () => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
const { Client } = require('@googlemaps/google-maps-services-js');
|
||||
|
||||
service = require('../../../services/googleMapsService');
|
||||
|
||||
expect(Client).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlacesAutocomplete', () => {
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
service = require('../../../services/googleMapsService');
|
||||
});
|
||||
|
||||
describe('Input validation', () => {
|
||||
it('should throw error when API key is not configured', async () => {
|
||||
service.apiKey = null;
|
||||
|
||||
await expect(service.getPlacesAutocomplete('test')).rejects.toThrow('Google Maps API key not configured');
|
||||
});
|
||||
|
||||
it('should return empty predictions for empty input', async () => {
|
||||
const result = await service.getPlacesAutocomplete('');
|
||||
|
||||
expect(result).toEqual({ predictions: [] });
|
||||
expect(mockPlaceAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty predictions for input less than 2 characters', async () => {
|
||||
const result = await service.getPlacesAutocomplete('a');
|
||||
|
||||
expect(result).toEqual({ predictions: [] });
|
||||
expect(mockPlaceAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trim input and proceed with valid input', async () => {
|
||||
mockPlaceAutocomplete.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
predictions: []
|
||||
}
|
||||
});
|
||||
|
||||
await service.getPlacesAutocomplete(' test ');
|
||||
|
||||
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
input: 'test'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameters handling', () => {
|
||||
beforeEach(() => {
|
||||
mockPlaceAutocomplete.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
predictions: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default parameters', async () => {
|
||||
await service.getPlacesAutocomplete('test input');
|
||||
|
||||
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||
params: {
|
||||
key: 'test-api-key',
|
||||
input: 'test input',
|
||||
types: 'address',
|
||||
language: 'en'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept custom options', async () => {
|
||||
const options = {
|
||||
types: 'establishment',
|
||||
language: 'fr'
|
||||
};
|
||||
|
||||
await service.getPlacesAutocomplete('test input', options);
|
||||
|
||||
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||
params: {
|
||||
key: 'test-api-key',
|
||||
input: 'test input',
|
||||
types: 'establishment',
|
||||
language: 'fr'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should include session token when provided', async () => {
|
||||
const options = {
|
||||
sessionToken: 'session-123'
|
||||
};
|
||||
|
||||
await service.getPlacesAutocomplete('test input', options);
|
||||
|
||||
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
sessiontoken: 'session-123'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle component restrictions', async () => {
|
||||
const options = {
|
||||
componentRestrictions: {
|
||||
country: 'us',
|
||||
administrative_area: 'CA'
|
||||
}
|
||||
};
|
||||
|
||||
await service.getPlacesAutocomplete('test input', options);
|
||||
|
||||
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
components: 'country:us|administrative_area:CA'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge additional options', async () => {
|
||||
const options = {
|
||||
radius: 1000,
|
||||
location: '40.7128,-74.0060'
|
||||
};
|
||||
|
||||
await service.getPlacesAutocomplete('test input', options);
|
||||
|
||||
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
radius: 1000,
|
||||
location: '40.7128,-74.0060'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful responses', () => {
|
||||
it('should return formatted predictions on success', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
predictions: [
|
||||
{
|
||||
place_id: 'ChIJ123',
|
||||
description: 'Test Location, City, State',
|
||||
types: ['establishment'],
|
||||
structured_formatting: {
|
||||
main_text: 'Test Location',
|
||||
secondary_text: 'City, State'
|
||||
}
|
||||
},
|
||||
{
|
||||
place_id: 'ChIJ456',
|
||||
description: 'Another Place',
|
||||
types: ['locality'],
|
||||
structured_formatting: {
|
||||
main_text: 'Another Place'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlacesAutocomplete('test input');
|
||||
|
||||
expect(result).toEqual({
|
||||
predictions: [
|
||||
{
|
||||
placeId: 'ChIJ123',
|
||||
description: 'Test Location, City, State',
|
||||
types: ['establishment'],
|
||||
mainText: 'Test Location',
|
||||
secondaryText: 'City, State'
|
||||
},
|
||||
{
|
||||
placeId: 'ChIJ456',
|
||||
description: 'Another Place',
|
||||
types: ['locality'],
|
||||
mainText: 'Another Place',
|
||||
secondaryText: ''
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle predictions without secondary text', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
predictions: [
|
||||
{
|
||||
place_id: 'ChIJ123',
|
||||
description: 'Test Location',
|
||||
types: ['establishment'],
|
||||
structured_formatting: {
|
||||
main_text: 'Test Location'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlacesAutocomplete('test input');
|
||||
|
||||
expect(result.predictions[0].secondaryText).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error responses', () => {
|
||||
it('should handle API error responses', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'ZERO_RESULTS',
|
||||
error_message: 'No results found'
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlacesAutocomplete('test input');
|
||||
|
||||
expect(result).toEqual({
|
||||
predictions: [],
|
||||
error: 'No results found for this query',
|
||||
status: 'ZERO_RESULTS'
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete API error:',
|
||||
'ZERO_RESULTS',
|
||||
'No results found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unknown error status', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'UNKNOWN_STATUS'
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlacesAutocomplete('test input');
|
||||
|
||||
expect(result.error).toBe('Google Maps API error: UNKNOWN_STATUS');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockPlaceAutocomplete.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(service.getPlacesAutocomplete('test input')).rejects.toThrow('Failed to fetch place predictions');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Places Autocomplete service error:', 'Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlaceDetails', () => {
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
service = require('../../../services/googleMapsService');
|
||||
});
|
||||
|
||||
describe('Input validation', () => {
|
||||
it('should throw error when API key is not configured', async () => {
|
||||
service.apiKey = null;
|
||||
|
||||
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API key not configured');
|
||||
});
|
||||
|
||||
it('should throw error when placeId is not provided', async () => {
|
||||
await expect(service.getPlaceDetails()).rejects.toThrow('Place ID is required');
|
||||
await expect(service.getPlaceDetails('')).rejects.toThrow('Place ID is required');
|
||||
await expect(service.getPlaceDetails(null)).rejects.toThrow('Place ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameters handling', () => {
|
||||
beforeEach(() => {
|
||||
mockPlaceDetails.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
result: {
|
||||
place_id: 'ChIJ123',
|
||||
formatted_address: 'Test Address',
|
||||
address_components: [],
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default parameters', async () => {
|
||||
await service.getPlaceDetails('ChIJ123');
|
||||
|
||||
expect(mockPlaceDetails).toHaveBeenCalledWith({
|
||||
params: {
|
||||
key: 'test-api-key',
|
||||
place_id: 'ChIJ123',
|
||||
fields: [
|
||||
'address_components',
|
||||
'formatted_address',
|
||||
'geometry',
|
||||
'place_id'
|
||||
],
|
||||
language: 'en'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept custom language', async () => {
|
||||
await service.getPlaceDetails('ChIJ123', { language: 'fr' });
|
||||
|
||||
expect(mockPlaceDetails).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
language: 'fr'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should include session token when provided', async () => {
|
||||
await service.getPlaceDetails('ChIJ123', { sessionToken: 'session-123' });
|
||||
|
||||
expect(mockPlaceDetails).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
sessiontoken: 'session-123'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful responses', () => {
|
||||
it('should return formatted place details', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
result: {
|
||||
place_id: 'ChIJ123',
|
||||
formatted_address: '123 Test St, Test City, TC 12345, USA',
|
||||
address_components: [
|
||||
{
|
||||
long_name: '123',
|
||||
short_name: '123',
|
||||
types: ['street_number']
|
||||
},
|
||||
{
|
||||
long_name: 'Test Street',
|
||||
short_name: 'Test St',
|
||||
types: ['route']
|
||||
},
|
||||
{
|
||||
long_name: 'Test City',
|
||||
short_name: 'Test City',
|
||||
types: ['locality', 'political']
|
||||
},
|
||||
{
|
||||
long_name: 'Test State',
|
||||
short_name: 'TS',
|
||||
types: ['administrative_area_level_1', 'political']
|
||||
},
|
||||
{
|
||||
long_name: '12345',
|
||||
short_name: '12345',
|
||||
types: ['postal_code']
|
||||
},
|
||||
{
|
||||
long_name: 'United States',
|
||||
short_name: 'US',
|
||||
types: ['country', 'political']
|
||||
}
|
||||
],
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlaceDetails('ChIJ123');
|
||||
|
||||
expect(result).toEqual({
|
||||
placeId: 'ChIJ123',
|
||||
formattedAddress: '123 Test St, Test City, TC 12345, USA',
|
||||
addressComponents: {
|
||||
streetNumber: '123',
|
||||
route: 'Test Street',
|
||||
locality: 'Test City',
|
||||
administrativeAreaLevel1: 'TS',
|
||||
administrativeAreaLevel1Long: 'Test State',
|
||||
postalCode: '12345',
|
||||
country: 'US'
|
||||
},
|
||||
geometry: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle place details without address components', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
result: {
|
||||
place_id: 'ChIJ123',
|
||||
formatted_address: 'Test Address',
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlaceDetails('ChIJ123');
|
||||
|
||||
expect(result.addressComponents).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle place details without geometry', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
result: {
|
||||
place_id: 'ChIJ123',
|
||||
formatted_address: 'Test Address'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlaceDetails('ChIJ123');
|
||||
|
||||
expect(result.geometry).toEqual({
|
||||
latitude: 0,
|
||||
longitude: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial geometry data', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
result: {
|
||||
place_id: 'ChIJ123',
|
||||
formatted_address: 'Test Address',
|
||||
geometry: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPlaceDetails('ChIJ123');
|
||||
|
||||
expect(result.geometry).toEqual({
|
||||
latitude: 0,
|
||||
longitude: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error responses', () => {
|
||||
it('should handle API error responses', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'NOT_FOUND',
|
||||
error_message: 'Place not found'
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('The specified place was not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Place Details API error:',
|
||||
'NOT_FOUND',
|
||||
'Place not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle response without result', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK'
|
||||
}
|
||||
};
|
||||
|
||||
mockPlaceDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API error: OK');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const originalError = new Error('Network error');
|
||||
mockPlaceDetails.mockRejectedValue(originalError);
|
||||
|
||||
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow(originalError);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Place Details service error:', 'Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('geocodeAddress', () => {
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
service = require('../../../services/googleMapsService');
|
||||
});
|
||||
|
||||
describe('Input validation', () => {
|
||||
it('should throw error when API key is not configured', async () => {
|
||||
service.apiKey = null;
|
||||
|
||||
await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Google Maps API key not configured');
|
||||
});
|
||||
|
||||
it('should throw error when address is not provided', async () => {
|
||||
await expect(service.geocodeAddress()).rejects.toThrow('Address is required for geocoding');
|
||||
await expect(service.geocodeAddress('')).rejects.toThrow('Address is required for geocoding');
|
||||
await expect(service.geocodeAddress(' ')).rejects.toThrow('Address is required for geocoding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameters handling', () => {
|
||||
beforeEach(() => {
|
||||
mockGeocode.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
results: [
|
||||
{
|
||||
formatted_address: 'Test Address',
|
||||
place_id: 'ChIJ123',
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default parameters', async () => {
|
||||
await service.geocodeAddress('123 Test St');
|
||||
|
||||
expect(mockGeocode).toHaveBeenCalledWith({
|
||||
params: {
|
||||
key: 'test-api-key',
|
||||
address: '123 Test St',
|
||||
language: 'en'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim address input', async () => {
|
||||
await service.geocodeAddress(' 123 Test St ');
|
||||
|
||||
expect(mockGeocode).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
address: '123 Test St'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept custom language', async () => {
|
||||
await service.geocodeAddress('123 Test St', { language: 'fr' });
|
||||
|
||||
expect(mockGeocode).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
language: 'fr'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle component restrictions', async () => {
|
||||
const options = {
|
||||
componentRestrictions: {
|
||||
country: 'us',
|
||||
administrative_area: 'CA'
|
||||
}
|
||||
};
|
||||
|
||||
await service.geocodeAddress('123 Test St', options);
|
||||
|
||||
expect(mockGeocode).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
components: 'country:us|administrative_area:CA'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle bounds parameter', async () => {
|
||||
const options = {
|
||||
bounds: '40.7,-74.1|40.8,-73.9'
|
||||
};
|
||||
|
||||
await service.geocodeAddress('123 Test St', options);
|
||||
|
||||
expect(mockGeocode).toHaveBeenCalledWith({
|
||||
params: expect.objectContaining({
|
||||
bounds: '40.7,-74.1|40.8,-73.9'
|
||||
}),
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful responses', () => {
|
||||
it('should return geocoded location', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
results: [
|
||||
{
|
||||
formatted_address: '123 Test St, Test City, TC 12345, USA',
|
||||
place_id: 'ChIJ123',
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
mockGeocode.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.geocodeAddress('123 Test St');
|
||||
|
||||
expect(result).toEqual({
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
formattedAddress: '123 Test St, Test City, TC 12345, USA',
|
||||
placeId: 'ChIJ123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return first result when multiple results', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
results: [
|
||||
{
|
||||
formatted_address: 'First Result',
|
||||
place_id: 'ChIJ123',
|
||||
geometry: { location: { lat: 40.7128, lng: -74.0060 } }
|
||||
},
|
||||
{
|
||||
formatted_address: 'Second Result',
|
||||
place_id: 'ChIJ456',
|
||||
geometry: { location: { lat: 40.7129, lng: -74.0061 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
mockGeocode.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.geocodeAddress('123 Test St');
|
||||
|
||||
expect(result.formattedAddress).toBe('First Result');
|
||||
expect(result.placeId).toBe('ChIJ123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error responses', () => {
|
||||
it('should handle API error responses', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'ZERO_RESULTS',
|
||||
error_message: 'No results found'
|
||||
}
|
||||
};
|
||||
|
||||
mockGeocode.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.geocodeAddress('123 Test St');
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'No results found for this query',
|
||||
status: 'ZERO_RESULTS'
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Geocoding API error:',
|
||||
'ZERO_RESULTS',
|
||||
'No results found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty results array', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
status: 'OK',
|
||||
results: []
|
||||
}
|
||||
};
|
||||
|
||||
mockGeocode.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.geocodeAddress('123 Test St');
|
||||
|
||||
expect(result.error).toBe('Google Maps API error: OK');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockGeocode.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Failed to geocode address');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Geocoding service error:', 'Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
service = require('../../../services/googleMapsService');
|
||||
});
|
||||
|
||||
it('should return correct error messages for known status codes', () => {
|
||||
expect(service.getErrorMessage('ZERO_RESULTS')).toBe('No results found for this query');
|
||||
expect(service.getErrorMessage('OVER_QUERY_LIMIT')).toBe('API quota exceeded. Please try again later');
|
||||
expect(service.getErrorMessage('REQUEST_DENIED')).toBe('API request denied. Check API key configuration');
|
||||
expect(service.getErrorMessage('INVALID_REQUEST')).toBe('Invalid request parameters');
|
||||
expect(service.getErrorMessage('UNKNOWN_ERROR')).toBe('Server error. Please try again');
|
||||
expect(service.getErrorMessage('NOT_FOUND')).toBe('The specified place was not found');
|
||||
});
|
||||
|
||||
it('should return generic error message for unknown status codes', () => {
|
||||
expect(service.getErrorMessage('UNKNOWN_STATUS')).toBe('Google Maps API error: UNKNOWN_STATUS');
|
||||
expect(service.getErrorMessage('CUSTOM_ERROR')).toBe('Google Maps API error: CUSTOM_ERROR');
|
||||
});
|
||||
|
||||
it('should handle null/undefined status', () => {
|
||||
expect(service.getErrorMessage(null)).toBe('Google Maps API error: null');
|
||||
expect(service.getErrorMessage(undefined)).toBe('Google Maps API error: undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should return true when API key is configured', () => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
service = require('../../../services/googleMapsService');
|
||||
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when API key is not configured', () => {
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
service = require('../../../services/googleMapsService');
|
||||
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when API key is empty string', () => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = '';
|
||||
service = require('../../../services/googleMapsService');
|
||||
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton pattern', () => {
|
||||
it('should return the same instance on multiple requires', () => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
|
||||
const service1 = require('../../../services/googleMapsService');
|
||||
const service2 = require('../../../services/googleMapsService');
|
||||
|
||||
expect(service1).toBe(service2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
service = require('../../../services/googleMapsService');
|
||||
});
|
||||
|
||||
it('should handle typical place search workflow', async () => {
|
||||
// Mock autocomplete response
|
||||
mockPlaceAutocomplete.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
predictions: [
|
||||
{
|
||||
place_id: 'ChIJ123',
|
||||
description: 'Test Location',
|
||||
types: ['establishment'],
|
||||
structured_formatting: {
|
||||
main_text: 'Test Location',
|
||||
secondary_text: 'City, State'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Mock place details response
|
||||
mockPlaceDetails.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
result: {
|
||||
place_id: 'ChIJ123',
|
||||
formatted_address: 'Test Location, City, State',
|
||||
address_components: [],
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: Get autocomplete predictions
|
||||
const autocompleteResult = await service.getPlacesAutocomplete('test loc');
|
||||
expect(autocompleteResult.predictions).toHaveLength(1);
|
||||
|
||||
// Step 2: Get detailed place information
|
||||
const placeId = autocompleteResult.predictions[0].placeId;
|
||||
const detailsResult = await service.getPlaceDetails(placeId);
|
||||
|
||||
expect(detailsResult.placeId).toBe('ChIJ123');
|
||||
expect(detailsResult.geometry.latitude).toBe(40.7128);
|
||||
expect(detailsResult.geometry.longitude).toBe(-74.0060);
|
||||
});
|
||||
|
||||
it('should handle geocoding workflow', async () => {
|
||||
mockGeocode.mockResolvedValue({
|
||||
data: {
|
||||
status: 'OK',
|
||||
results: [
|
||||
{
|
||||
formatted_address: '123 Test St, Test City, TC 12345, USA',
|
||||
place_id: 'ChIJ123',
|
||||
geometry: {
|
||||
location: { lat: 40.7128, lng: -74.0060 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const result = await service.geocodeAddress('123 Test St, Test City, TC');
|
||||
|
||||
expect(result.latitude).toBe(40.7128);
|
||||
expect(result.longitude).toBe(-74.0060);
|
||||
expect(result.formattedAddress).toBe('123 Test St, Test City, TC 12345, USA');
|
||||
});
|
||||
});
|
||||
});
|
||||
743
backend/tests/unit/services/payoutService.test.js
Normal file
743
backend/tests/unit/services/payoutService.test.js
Normal file
@@ -0,0 +1,743 @@
|
||||
// Mock dependencies
|
||||
const mockRentalFindAll = jest.fn();
|
||||
const mockRentalUpdate = jest.fn();
|
||||
const mockUserModel = jest.fn();
|
||||
const mockCreateTransfer = jest.fn();
|
||||
|
||||
jest.mock('../../../models', () => ({
|
||||
Rental: {
|
||||
findAll: mockRentalFindAll,
|
||||
update: mockRentalUpdate
|
||||
},
|
||||
User: mockUserModel
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/stripeService', () => ({
|
||||
createTransfer: mockCreateTransfer
|
||||
}));
|
||||
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
not: 'not'
|
||||
}
|
||||
}));
|
||||
|
||||
const PayoutService = require('../../../services/payoutService');
|
||||
|
||||
describe('PayoutService', () => {
|
||||
let consoleSpy, consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getEligiblePayouts', () => {
|
||||
it('should return eligible rentals for payout', async () => {
|
||||
const mockRentals = [
|
||||
{
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
paymentStatus: 'paid',
|
||||
payoutStatus: 'pending',
|
||||
owner: {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: 'acct_123'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'completed',
|
||||
paymentStatus: 'paid',
|
||||
payoutStatus: 'pending',
|
||||
owner: {
|
||||
id: 2,
|
||||
stripeConnectedAccountId: 'acct_456'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockRentals);
|
||||
|
||||
const result = await PayoutService.getEligiblePayouts();
|
||||
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'completed',
|
||||
paymentStatus: 'paid',
|
||||
payoutStatus: 'pending'
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: mockUserModel,
|
||||
as: 'owner',
|
||||
where: {
|
||||
stripeConnectedAccountId: {
|
||||
'not': null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockRentals);
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
const dbError = new Error('Database connection failed');
|
||||
mockRentalFindAll.mockRejectedValue(dbError);
|
||||
|
||||
await expect(PayoutService.getEligiblePayouts()).rejects.toThrow('Database connection failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting eligible payouts:', dbError);
|
||||
});
|
||||
|
||||
it('should return empty array when no eligible rentals found', async () => {
|
||||
mockRentalFindAll.mockResolvedValue([]);
|
||||
|
||||
const result = await PayoutService.getEligiblePayouts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processRentalPayout', () => {
|
||||
let mockRental;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
payoutStatus: 'pending',
|
||||
payoutAmount: 9500, // $95.00
|
||||
totalAmount: 10000, // $100.00
|
||||
platformFee: 500, // $5.00
|
||||
startDateTime: new Date('2023-01-01T10:00:00Z'),
|
||||
endDateTime: new Date('2023-01-02T10:00:00Z'),
|
||||
owner: {
|
||||
id: 2,
|
||||
stripeConnectedAccountId: 'acct_123'
|
||||
},
|
||||
update: jest.fn().mockResolvedValue(true)
|
||||
};
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should throw error when owner has no connected Stripe account', async () => {
|
||||
mockRental.owner.stripeConnectedAccountId = null;
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||
});
|
||||
|
||||
it('should throw error when owner is missing', async () => {
|
||||
mockRental.owner = null;
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||
});
|
||||
|
||||
it('should throw error when payout already processed', async () => {
|
||||
mockRental.payoutStatus = 'completed';
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Rental payout has already been processed');
|
||||
});
|
||||
|
||||
it('should throw error when payout amount is invalid', async () => {
|
||||
mockRental.payoutAmount = 0;
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Invalid payout amount');
|
||||
});
|
||||
|
||||
it('should throw error when payout amount is negative', async () => {
|
||||
mockRental.payoutAmount = -100;
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Invalid payout amount');
|
||||
});
|
||||
|
||||
it('should throw error when payout amount is null', async () => {
|
||||
mockRental.payoutAmount = null;
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Invalid payout amount');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful processing', () => {
|
||||
beforeEach(() => {
|
||||
mockCreateTransfer.mockResolvedValue({
|
||||
id: 'tr_123456789',
|
||||
amount: 9500,
|
||||
destination: 'acct_123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully process a rental payout', async () => {
|
||||
const result = await PayoutService.processRentalPayout(mockRental);
|
||||
|
||||
// Verify status update to processing
|
||||
expect(mockRental.update).toHaveBeenNthCalledWith(1, {
|
||||
payoutStatus: 'processing'
|
||||
});
|
||||
|
||||
// Verify Stripe transfer creation
|
||||
expect(mockCreateTransfer).toHaveBeenCalledWith({
|
||||
amount: 9500,
|
||||
destination: 'acct_123',
|
||||
metadata: {
|
||||
rentalId: 1,
|
||||
ownerId: 2,
|
||||
totalAmount: '10000',
|
||||
platformFee: '500',
|
||||
startDateTime: '2023-01-01T10:00:00.000Z',
|
||||
endDateTime: '2023-01-02T10:00:00.000Z'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify status update to completed
|
||||
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
|
||||
payoutStatus: 'completed',
|
||||
payoutProcessedAt: expect.any(Date),
|
||||
stripeTransferId: 'tr_123456789'
|
||||
});
|
||||
|
||||
// Verify success log
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Payout completed for rental 1: $9500 to acct_123'
|
||||
);
|
||||
|
||||
// Verify return value
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
transferId: 'tr_123456789',
|
||||
amount: 9500
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle successful payout with different amounts', async () => {
|
||||
mockRental.payoutAmount = 15000;
|
||||
mockRental.totalAmount = 16000;
|
||||
mockRental.platformFee = 1000;
|
||||
|
||||
mockCreateTransfer.mockResolvedValue({
|
||||
id: 'tr_987654321',
|
||||
amount: 15000,
|
||||
destination: 'acct_123'
|
||||
});
|
||||
|
||||
const result = await PayoutService.processRentalPayout(mockRental);
|
||||
|
||||
expect(mockCreateTransfer).toHaveBeenCalledWith({
|
||||
amount: 15000,
|
||||
destination: 'acct_123',
|
||||
metadata: expect.objectContaining({
|
||||
totalAmount: '16000',
|
||||
platformFee: '1000'
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(15000);
|
||||
expect(result.transferId).toBe('tr_987654321');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle Stripe transfer creation errors', async () => {
|
||||
const stripeError = new Error('Stripe transfer failed');
|
||||
mockCreateTransfer.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Stripe transfer failed');
|
||||
|
||||
// Verify processing status was set
|
||||
expect(mockRental.update).toHaveBeenNthCalledWith(1, {
|
||||
payoutStatus: 'processing'
|
||||
});
|
||||
|
||||
// Verify failure status was set
|
||||
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
|
||||
payoutStatus: 'failed'
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error processing payout for rental 1:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors during processing', async () => {
|
||||
const dbError = new Error('Database update failed');
|
||||
mockRental.update.mockRejectedValueOnce(dbError);
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Database update failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error processing payout for rental 1:',
|
||||
dbError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors during completion', async () => {
|
||||
mockCreateTransfer.mockResolvedValue({
|
||||
id: 'tr_123456789',
|
||||
amount: 9500
|
||||
});
|
||||
|
||||
const dbError = new Error('Database completion update failed');
|
||||
mockRental.update
|
||||
.mockResolvedValueOnce(true) // processing update succeeds
|
||||
.mockRejectedValueOnce(dbError); // completion update fails
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Database completion update failed');
|
||||
|
||||
expect(mockCreateTransfer).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error processing payout for rental 1:',
|
||||
dbError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle failure status update errors gracefully', async () => {
|
||||
const stripeError = new Error('Stripe transfer failed');
|
||||
const updateError = new Error('Update failed status failed');
|
||||
|
||||
mockCreateTransfer.mockRejectedValue(stripeError);
|
||||
mockRental.update
|
||||
.mockResolvedValueOnce(true) // processing update succeeds
|
||||
.mockRejectedValueOnce(updateError); // failed status update fails
|
||||
|
||||
// The service will throw the update error since it happens in the catch block
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Update failed status failed');
|
||||
|
||||
// Should still attempt to update to failed status
|
||||
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
|
||||
payoutStatus: 'failed'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processAllEligiblePayouts', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(PayoutService, 'getEligiblePayouts');
|
||||
jest.spyOn(PayoutService, 'processRentalPayout');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PayoutService.getEligiblePayouts.mockRestore();
|
||||
PayoutService.processRentalPayout.mockRestore();
|
||||
});
|
||||
|
||||
it('should process all eligible payouts successfully', async () => {
|
||||
const mockRentals = [
|
||||
{ id: 1, payoutAmount: 9500 },
|
||||
{ id: 2, payoutAmount: 7500 }
|
||||
];
|
||||
|
||||
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
|
||||
PayoutService.processRentalPayout
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_123',
|
||||
amount: 9500
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_456',
|
||||
amount: 7500
|
||||
});
|
||||
|
||||
const result = await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Found 2 eligible rentals for payout');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 0 failed');
|
||||
|
||||
expect(result).toEqual({
|
||||
successful: [
|
||||
{ rentalId: 1, amount: 9500, transferId: 'tr_123' },
|
||||
{ rentalId: 2, amount: 7500, transferId: 'tr_456' }
|
||||
],
|
||||
failed: [],
|
||||
totalProcessed: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed success and failure results', async () => {
|
||||
const mockRentals = [
|
||||
{ id: 1, payoutAmount: 9500 },
|
||||
{ id: 2, payoutAmount: 7500 },
|
||||
{ id: 3, payoutAmount: 12000 }
|
||||
];
|
||||
|
||||
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
|
||||
PayoutService.processRentalPayout
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_123',
|
||||
amount: 9500
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Stripe account suspended'))
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_789',
|
||||
amount: 12000
|
||||
});
|
||||
|
||||
const result = await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed');
|
||||
|
||||
expect(result).toEqual({
|
||||
successful: [
|
||||
{ rentalId: 1, amount: 9500, transferId: 'tr_123' },
|
||||
{ rentalId: 3, amount: 12000, transferId: 'tr_789' }
|
||||
],
|
||||
failed: [
|
||||
{ rentalId: 2, error: 'Stripe account suspended' }
|
||||
],
|
||||
totalProcessed: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle no eligible payouts', async () => {
|
||||
PayoutService.getEligiblePayouts.mockResolvedValue([]);
|
||||
|
||||
const result = await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Found 0 eligible rentals for payout');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 0 successful, 0 failed');
|
||||
|
||||
expect(result).toEqual({
|
||||
successful: [],
|
||||
failed: [],
|
||||
totalProcessed: 0
|
||||
});
|
||||
|
||||
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors in getEligiblePayouts', async () => {
|
||||
const dbError = new Error('Database connection failed');
|
||||
PayoutService.getEligiblePayouts.mockRejectedValue(dbError);
|
||||
|
||||
await expect(PayoutService.processAllEligiblePayouts())
|
||||
.rejects.toThrow('Database connection failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error processing all eligible payouts:',
|
||||
dbError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all payouts failing', async () => {
|
||||
const mockRentals = [
|
||||
{ id: 1, payoutAmount: 9500 },
|
||||
{ id: 2, payoutAmount: 7500 }
|
||||
];
|
||||
|
||||
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
|
||||
PayoutService.processRentalPayout
|
||||
.mockRejectedValueOnce(new Error('Transfer failed'))
|
||||
.mockRejectedValueOnce(new Error('Account not found'));
|
||||
|
||||
const result = await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
expect(result).toEqual({
|
||||
successful: [],
|
||||
failed: [
|
||||
{ rentalId: 1, error: 'Transfer failed' },
|
||||
{ rentalId: 2, error: 'Account not found' }
|
||||
],
|
||||
totalProcessed: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryFailedPayouts', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(PayoutService, 'processRentalPayout');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PayoutService.processRentalPayout.mockRestore();
|
||||
});
|
||||
|
||||
it('should retry failed payouts successfully', async () => {
|
||||
const mockFailedRentals = [
|
||||
{
|
||||
id: 1,
|
||||
payoutAmount: 9500,
|
||||
update: jest.fn().mockResolvedValue(true)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
payoutAmount: 7500,
|
||||
update: jest.fn().mockResolvedValue(true)
|
||||
}
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
|
||||
PayoutService.processRentalPayout
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_retry_123',
|
||||
amount: 9500
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_retry_456',
|
||||
amount: 7500
|
||||
});
|
||||
|
||||
const result = await PayoutService.retryFailedPayouts();
|
||||
|
||||
// Verify query for failed rentals
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'completed',
|
||||
paymentStatus: 'paid',
|
||||
payoutStatus: 'failed'
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: mockUserModel,
|
||||
as: 'owner',
|
||||
where: {
|
||||
stripeConnectedAccountId: {
|
||||
'not': null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Verify status reset to pending
|
||||
expect(mockFailedRentals[0].update).toHaveBeenCalledWith({ payoutStatus: 'pending' });
|
||||
expect(mockFailedRentals[1].update).toHaveBeenCalledWith({ payoutStatus: 'pending' });
|
||||
|
||||
// Verify processing attempts
|
||||
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[0]);
|
||||
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[1]);
|
||||
|
||||
// Verify logs
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Found 2 failed payouts to retry');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 2 successful, 0 failed');
|
||||
|
||||
// Verify result
|
||||
expect(result).toEqual({
|
||||
successful: [
|
||||
{ rentalId: 1, amount: 9500, transferId: 'tr_retry_123' },
|
||||
{ rentalId: 2, amount: 7500, transferId: 'tr_retry_456' }
|
||||
],
|
||||
failed: [],
|
||||
totalProcessed: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed retry results', async () => {
|
||||
const mockFailedRentals = [
|
||||
{
|
||||
id: 1,
|
||||
payoutAmount: 9500,
|
||||
update: jest.fn().mockResolvedValue(true)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
payoutAmount: 7500,
|
||||
update: jest.fn().mockResolvedValue(true)
|
||||
}
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
|
||||
PayoutService.processRentalPayout
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
transferId: 'tr_retry_123',
|
||||
amount: 9500
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Still failing'));
|
||||
|
||||
const result = await PayoutService.retryFailedPayouts();
|
||||
|
||||
expect(result).toEqual({
|
||||
successful: [
|
||||
{ rentalId: 1, amount: 9500, transferId: 'tr_retry_123' }
|
||||
],
|
||||
failed: [
|
||||
{ rentalId: 2, error: 'Still failing' }
|
||||
],
|
||||
totalProcessed: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle no failed payouts to retry', async () => {
|
||||
mockRentalFindAll.mockResolvedValue([]);
|
||||
|
||||
const result = await PayoutService.retryFailedPayouts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Found 0 failed payouts to retry');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 0 successful, 0 failed');
|
||||
|
||||
expect(result).toEqual({
|
||||
successful: [],
|
||||
failed: [],
|
||||
totalProcessed: 0
|
||||
});
|
||||
|
||||
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors in finding failed rentals', async () => {
|
||||
const dbError = new Error('Database query failed');
|
||||
mockRentalFindAll.mockRejectedValue(dbError);
|
||||
|
||||
await expect(PayoutService.retryFailedPayouts())
|
||||
.rejects.toThrow('Database query failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error retrying failed payouts:',
|
||||
dbError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle status reset errors', async () => {
|
||||
const mockFailedRentals = [
|
||||
{
|
||||
id: 1,
|
||||
payoutAmount: 9500,
|
||||
update: jest.fn().mockRejectedValue(new Error('Status reset failed'))
|
||||
}
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
|
||||
|
||||
const result = await PayoutService.retryFailedPayouts();
|
||||
|
||||
expect(result.failed).toEqual([
|
||||
{ rentalId: 1, error: 'Status reset failed' }
|
||||
]);
|
||||
|
||||
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error logging', () => {
|
||||
it('should log errors with rental context in processRentalPayout', async () => {
|
||||
const mockRental = {
|
||||
id: 123,
|
||||
payoutStatus: 'pending',
|
||||
payoutAmount: 9500,
|
||||
owner: {
|
||||
stripeConnectedAccountId: 'acct_123'
|
||||
},
|
||||
update: jest.fn().mockRejectedValue(new Error('Update failed'))
|
||||
};
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Update failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error processing payout for rental 123:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('should log aggregate results in processAllEligiblePayouts', async () => {
|
||||
jest.spyOn(PayoutService, 'getEligiblePayouts').mockResolvedValue([
|
||||
{ id: 1 }, { id: 2 }, { id: 3 }
|
||||
]);
|
||||
jest.spyOn(PayoutService, 'processRentalPayout')
|
||||
.mockResolvedValueOnce({ amount: 100, transferId: 'tr_1' })
|
||||
.mockRejectedValueOnce(new Error('Failed'))
|
||||
.mockResolvedValueOnce({ amount: 300, transferId: 'tr_3' });
|
||||
|
||||
await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed');
|
||||
|
||||
PayoutService.getEligiblePayouts.mockRestore();
|
||||
PayoutService.processRentalPayout.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle rental with undefined owner', async () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
payoutStatus: 'pending',
|
||||
payoutAmount: 9500,
|
||||
owner: undefined,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||
});
|
||||
|
||||
it('should handle rental with empty string Stripe account ID', async () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
payoutStatus: 'pending',
|
||||
payoutAmount: 9500,
|
||||
owner: {
|
||||
stripeConnectedAccountId: ''
|
||||
},
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
await expect(PayoutService.processRentalPayout(mockRental))
|
||||
.rejects.toThrow('Owner does not have a connected Stripe account');
|
||||
});
|
||||
|
||||
it('should handle very large payout amounts', async () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
payoutStatus: 'pending',
|
||||
payoutAmount: 999999999, // Very large amount
|
||||
totalAmount: 1000000000,
|
||||
platformFee: 1,
|
||||
startDateTime: new Date('2023-01-01T10:00:00Z'),
|
||||
endDateTime: new Date('2023-01-02T10:00:00Z'),
|
||||
owner: {
|
||||
stripeConnectedAccountId: 'acct_123'
|
||||
},
|
||||
update: jest.fn().mockResolvedValue(true)
|
||||
};
|
||||
|
||||
mockCreateTransfer.mockResolvedValue({
|
||||
id: 'tr_large_amount',
|
||||
amount: 999999999
|
||||
});
|
||||
|
||||
const result = await PayoutService.processRentalPayout(mockRental);
|
||||
|
||||
expect(mockCreateTransfer).toHaveBeenCalledWith({
|
||||
amount: 999999999,
|
||||
destination: 'acct_123',
|
||||
metadata: expect.objectContaining({
|
||||
totalAmount: '1000000000',
|
||||
platformFee: '1'
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(999999999);
|
||||
});
|
||||
});
|
||||
});
|
||||
684
backend/tests/unit/services/refundService.test.js
Normal file
684
backend/tests/unit/services/refundService.test.js
Normal file
@@ -0,0 +1,684 @@
|
||||
// Mock dependencies
|
||||
const mockRentalFindByPk = jest.fn();
|
||||
const mockRentalUpdate = jest.fn();
|
||||
const mockCreateRefund = jest.fn();
|
||||
|
||||
jest.mock('../../../models', () => ({
|
||||
Rental: {
|
||||
findByPk: mockRentalFindByPk
|
||||
}
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/stripeService', () => ({
|
||||
createRefund: mockCreateRefund
|
||||
}));
|
||||
|
||||
const RefundService = require('../../../services/refundService');
|
||||
|
||||
describe('RefundService', () => {
|
||||
let consoleSpy, consoleErrorSpy, consoleWarnSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('calculateRefundAmount', () => {
|
||||
const baseRental = {
|
||||
totalAmount: 100.00,
|
||||
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||
};
|
||||
|
||||
describe('Owner cancellation', () => {
|
||||
it('should return 100% refund when cancelled by owner', () => {
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'owner');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 100.00,
|
||||
refundPercentage: 1.0,
|
||||
reason: 'Full refund - cancelled by owner'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle decimal amounts correctly for owner cancellation', () => {
|
||||
const rental = { ...baseRental, totalAmount: 125.75 };
|
||||
const result = RefundService.calculateRefundAmount(rental, 'owner');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 125.75,
|
||||
refundPercentage: 1.0,
|
||||
reason: 'Full refund - cancelled by owner'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Renter cancellation', () => {
|
||||
it('should return 0% refund when cancelled within 24 hours', () => {
|
||||
// Use fake timers to set the current time
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-30T15:00:00Z')); // 19 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 0.00,
|
||||
refundPercentage: 0.0,
|
||||
reason: 'No refund - cancelled within 24 hours of start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return 50% refund when cancelled between 24-48 hours', () => {
|
||||
// Use fake timers to set the current time
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-29T15:00:00Z')); // 43 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 50.00,
|
||||
refundPercentage: 0.5,
|
||||
reason: '50% refund - cancelled between 24-48 hours of start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return 100% refund when cancelled more than 48 hours before', () => {
|
||||
// Use fake timers to set the current time
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-28T15:00:00Z')); // 67 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 100.00,
|
||||
refundPercentage: 1.0,
|
||||
reason: 'Full refund - cancelled more than 48 hours before start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle decimal calculations correctly for 50% refund', () => {
|
||||
const rental = { ...baseRental, totalAmount: 127.33 };
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-29T15:00:00Z')); // 43 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(rental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 63.66, // 127.33 * 0.5 = 63.665, rounded to 63.66
|
||||
refundPercentage: 0.5,
|
||||
reason: '50% refund - cancelled between 24-48 hours of start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle edge case exactly at 24 hours', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-30T10:00:00Z')); // exactly 24 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 50.00,
|
||||
refundPercentage: 0.5,
|
||||
reason: '50% refund - cancelled between 24-48 hours of start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle edge case exactly at 48 hours', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-29T10:00:00Z')); // exactly 48 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 100.00,
|
||||
refundPercentage: 1.0,
|
||||
reason: 'Full refund - cancelled more than 48 hours before start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle zero total amount', () => {
|
||||
const rental = { ...baseRental, totalAmount: 0 };
|
||||
const result = RefundService.calculateRefundAmount(rental, 'owner');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 0.00,
|
||||
refundPercentage: 1.0,
|
||||
reason: 'Full refund - cancelled by owner'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown cancelledBy value', () => {
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'unknown');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 0.00,
|
||||
refundPercentage: 0,
|
||||
reason: ''
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle past rental start time for renter cancellation', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-12-02T10:00:00Z')); // 24 hours after start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(baseRental, 'renter');
|
||||
|
||||
expect(result).toEqual({
|
||||
refundAmount: 0.00,
|
||||
refundPercentage: 0.0,
|
||||
reason: 'No refund - cancelled within 24 hours of start time'
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCancellationEligibility', () => {
|
||||
const baseRental = {
|
||||
id: 1,
|
||||
renterId: 100,
|
||||
ownerId: 200,
|
||||
status: 'pending',
|
||||
paymentStatus: 'paid'
|
||||
};
|
||||
|
||||
describe('Status validation', () => {
|
||||
it('should reject cancellation for already cancelled rental', () => {
|
||||
const rental = { ...baseRental, status: 'cancelled' };
|
||||
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'Rental is already cancelled',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject cancellation for completed rental', () => {
|
||||
const rental = { ...baseRental, status: 'completed' };
|
||||
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'Cannot cancel completed rental',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject cancellation for active rental', () => {
|
||||
const rental = { ...baseRental, status: 'active' };
|
||||
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'Cannot cancel active rental',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization validation', () => {
|
||||
it('should allow renter to cancel', () => {
|
||||
const result = RefundService.validateCancellationEligibility(baseRental, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: true,
|
||||
reason: 'Cancellation allowed',
|
||||
cancelledBy: 'renter'
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow owner to cancel', () => {
|
||||
const result = RefundService.validateCancellationEligibility(baseRental, 200);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: true,
|
||||
reason: 'Cancellation allowed',
|
||||
cancelledBy: 'owner'
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject unauthorized user', () => {
|
||||
const result = RefundService.validateCancellationEligibility(baseRental, 999);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'You are not authorized to cancel this rental',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payment status validation', () => {
|
||||
it('should reject cancellation for unpaid rental', () => {
|
||||
const rental = { ...baseRental, paymentStatus: 'pending' };
|
||||
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'Cannot cancel rental that hasn\'t been paid',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject cancellation for failed payment', () => {
|
||||
const rental = { ...baseRental, paymentStatus: 'failed' };
|
||||
const result = RefundService.validateCancellationEligibility(rental, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'Cannot cancel rental that hasn\'t been paid',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle string user IDs that don\'t match', () => {
|
||||
const result = RefundService.validateCancellationEligibility(baseRental, '100');
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'You are not authorized to cancel this rental',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null user ID', () => {
|
||||
const result = RefundService.validateCancellationEligibility(baseRental, null);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: false,
|
||||
reason: 'You are not authorized to cancel this rental',
|
||||
cancelledBy: null
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processCancellation', () => {
|
||||
let mockRental;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRental = {
|
||||
id: 1,
|
||||
renterId: 100,
|
||||
ownerId: 200,
|
||||
status: 'pending',
|
||||
paymentStatus: 'paid',
|
||||
totalAmount: 100.00,
|
||||
stripePaymentIntentId: 'pi_123456789',
|
||||
startDateTime: new Date('2023-12-01T10:00:00Z'),
|
||||
update: mockRentalUpdate
|
||||
};
|
||||
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
mockRentalUpdate.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
describe('Rental not found', () => {
|
||||
it('should throw error when rental not found', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
await expect(RefundService.processCancellation('999', 100))
|
||||
.rejects.toThrow('Rental not found');
|
||||
|
||||
expect(mockRentalFindByPk).toHaveBeenCalledWith('999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation failures', () => {
|
||||
it('should throw error for invalid cancellation', async () => {
|
||||
mockRental.status = 'cancelled';
|
||||
|
||||
await expect(RefundService.processCancellation(1, 100))
|
||||
.rejects.toThrow('Rental is already cancelled');
|
||||
});
|
||||
|
||||
it('should throw error for unauthorized user', async () => {
|
||||
await expect(RefundService.processCancellation(1, 999))
|
||||
.rejects.toThrow('You are not authorized to cancel this rental');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful cancellation with refund', () => {
|
||||
beforeEach(() => {
|
||||
// Set time to more than 48 hours before start for full refund
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-28T10:00:00Z'));
|
||||
|
||||
mockCreateRefund.mockResolvedValue({
|
||||
id: 're_123456789',
|
||||
amount: 10000 // Stripe uses cents
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should process owner cancellation with full refund', async () => {
|
||||
const result = await RefundService.processCancellation(1, 200, 'Owner needs to cancel');
|
||||
|
||||
// Verify Stripe refund was created
|
||||
expect(mockCreateRefund).toHaveBeenCalledWith({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 100.00,
|
||||
metadata: {
|
||||
rentalId: 1,
|
||||
cancelledBy: 'owner',
|
||||
refundReason: 'Full refund - cancelled by owner'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify rental was updated
|
||||
expect(mockRentalUpdate).toHaveBeenCalledWith({
|
||||
status: 'cancelled',
|
||||
cancelledBy: 'owner',
|
||||
cancelledAt: expect.any(Date),
|
||||
refundAmount: 100.00,
|
||||
refundProcessedAt: expect.any(Date),
|
||||
refundReason: 'Owner needs to cancel',
|
||||
stripeRefundId: 're_123456789',
|
||||
payoutStatus: 'pending'
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
rental: mockRental,
|
||||
refund: {
|
||||
amount: 100.00,
|
||||
percentage: 1.0,
|
||||
reason: 'Full refund - cancelled by owner',
|
||||
processed: true,
|
||||
stripeRefundId: 're_123456789'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should process renter cancellation with partial refund', async () => {
|
||||
// Set time to 36 hours before start for 50% refund
|
||||
jest.useRealTimers(); // Reset timers first
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start
|
||||
|
||||
mockCreateRefund.mockResolvedValue({
|
||||
id: 're_partial',
|
||||
amount: 5000 // 50% in cents
|
||||
});
|
||||
|
||||
const result = await RefundService.processCancellation(1, 100);
|
||||
|
||||
expect(mockCreateRefund).toHaveBeenCalledWith({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 50.00,
|
||||
metadata: {
|
||||
rentalId: 1,
|
||||
cancelledBy: 'renter',
|
||||
refundReason: '50% refund - cancelled between 24-48 hours of start time'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.refund).toEqual({
|
||||
amount: 50.00,
|
||||
percentage: 0.5,
|
||||
reason: '50% refund - cancelled between 24-48 hours of start time',
|
||||
processed: true,
|
||||
stripeRefundId: 're_partial'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('No refund scenarios', () => {
|
||||
beforeEach(() => {
|
||||
// Set time to within 24 hours for no refund
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-30T15:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle cancellation with no refund', async () => {
|
||||
const result = await RefundService.processCancellation(1, 100);
|
||||
|
||||
// Verify no Stripe refund was attempted
|
||||
expect(mockCreateRefund).not.toHaveBeenCalled();
|
||||
|
||||
// Verify rental was updated
|
||||
expect(mockRentalUpdate).toHaveBeenCalledWith({
|
||||
status: 'cancelled',
|
||||
cancelledBy: 'renter',
|
||||
cancelledAt: expect.any(Date),
|
||||
refundAmount: 0.00,
|
||||
refundProcessedAt: null,
|
||||
refundReason: 'No refund - cancelled within 24 hours of start time',
|
||||
stripeRefundId: null,
|
||||
payoutStatus: 'pending'
|
||||
});
|
||||
|
||||
expect(result.refund).toEqual({
|
||||
amount: 0.00,
|
||||
percentage: 0.0,
|
||||
reason: 'No refund - cancelled within 24 hours of start time',
|
||||
processed: false,
|
||||
stripeRefundId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refund without payment intent ID', async () => {
|
||||
mockRental.stripePaymentIntentId = null;
|
||||
// Set to full refund scenario
|
||||
jest.useRealTimers();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-28T10:00:00Z'));
|
||||
|
||||
const result = await RefundService.processCancellation(1, 200);
|
||||
|
||||
expect(mockCreateRefund).not.toHaveBeenCalled();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Refund amount calculated but no payment intent ID for rental 1'
|
||||
);
|
||||
|
||||
expect(result.refund).toEqual({
|
||||
amount: 100.00,
|
||||
percentage: 1.0,
|
||||
reason: 'Full refund - cancelled by owner',
|
||||
processed: false,
|
||||
stripeRefundId: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle Stripe refund errors', async () => {
|
||||
const stripeError = new Error('Refund failed');
|
||||
mockCreateRefund.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(RefundService.processCancellation(1, 200))
|
||||
.rejects.toThrow('Failed to process refund: Refund failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error processing Stripe refund:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const dbError = new Error('Database update failed');
|
||||
mockRentalUpdate.mockRejectedValue(dbError);
|
||||
|
||||
mockCreateRefund.mockResolvedValue({
|
||||
id: 're_123456789'
|
||||
});
|
||||
|
||||
await expect(RefundService.processCancellation(1, 200))
|
||||
.rejects.toThrow('Database update failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefundPreview', () => {
|
||||
let mockRental;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRental = {
|
||||
id: 1,
|
||||
renterId: 100,
|
||||
ownerId: 200,
|
||||
status: 'pending',
|
||||
paymentStatus: 'paid',
|
||||
totalAmount: 150.00,
|
||||
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||
};
|
||||
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
describe('Successful preview', () => {
|
||||
it('should return owner cancellation preview', async () => {
|
||||
const result = await RefundService.getRefundPreview(1, 200);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: true,
|
||||
cancelledBy: 'owner',
|
||||
refundAmount: 150.00,
|
||||
refundPercentage: 1.0,
|
||||
reason: 'Full refund - cancelled by owner',
|
||||
totalAmount: 150.00
|
||||
});
|
||||
});
|
||||
|
||||
it('should return renter cancellation preview with partial refund', async () => {
|
||||
// Set time for 50% refund
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start
|
||||
|
||||
const result = await RefundService.getRefundPreview(1, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: true,
|
||||
cancelledBy: 'renter',
|
||||
refundAmount: 75.00,
|
||||
refundPercentage: 0.5,
|
||||
reason: '50% refund - cancelled between 24-48 hours of start time',
|
||||
totalAmount: 150.00
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return renter cancellation preview with no refund', async () => {
|
||||
// Set time for no refund
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-30T15:00:00Z'));
|
||||
|
||||
const result = await RefundService.getRefundPreview(1, 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
canCancel: true,
|
||||
cancelledBy: 'renter',
|
||||
refundAmount: 0.00,
|
||||
refundPercentage: 0.0,
|
||||
reason: 'No refund - cancelled within 24 hours of start time',
|
||||
totalAmount: 150.00
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error cases', () => {
|
||||
it('should throw error when rental not found', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
await expect(RefundService.getRefundPreview('999', 100))
|
||||
.rejects.toThrow('Rental not found');
|
||||
});
|
||||
|
||||
it('should throw error for invalid cancellation', async () => {
|
||||
mockRental.status = 'cancelled';
|
||||
|
||||
await expect(RefundService.getRefundPreview(1, 100))
|
||||
.rejects.toThrow('Rental is already cancelled');
|
||||
});
|
||||
|
||||
it('should throw error for unauthorized user', async () => {
|
||||
await expect(RefundService.getRefundPreview(1, 999))
|
||||
.rejects.toThrow('You are not authorized to cancel this rental');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error scenarios', () => {
|
||||
it('should handle invalid rental IDs in processCancellation', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
await expect(RefundService.processCancellation('invalid', 100))
|
||||
.rejects.toThrow('Rental not found');
|
||||
});
|
||||
|
||||
it('should handle very large refund amounts', async () => {
|
||||
const rental = {
|
||||
totalAmount: 999999.99,
|
||||
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||
};
|
||||
|
||||
const result = RefundService.calculateRefundAmount(rental, 'owner');
|
||||
|
||||
expect(result.refundAmount).toBe(999999.99);
|
||||
expect(result.refundPercentage).toBe(1.0);
|
||||
});
|
||||
|
||||
it('should handle refund amount rounding edge cases', async () => {
|
||||
const rental = {
|
||||
totalAmount: 33.333,
|
||||
startDateTime: new Date('2023-12-01T10:00:00Z')
|
||||
};
|
||||
|
||||
// Set time for 50% refund
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start
|
||||
|
||||
const result = RefundService.calculateRefundAmount(rental, 'renter');
|
||||
|
||||
expect(result.refundAmount).toBe(16.67); // 33.333 * 0.5 = 16.6665, rounded to 16.67
|
||||
expect(result.refundPercentage).toBe(0.5);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
988
backend/tests/unit/services/stripeService.test.js
Normal file
988
backend/tests/unit/services/stripeService.test.js
Normal file
@@ -0,0 +1,988 @@
|
||||
// Mock Stripe SDK
|
||||
const mockStripeCheckoutSessionsRetrieve = jest.fn();
|
||||
const mockStripeAccountsCreate = jest.fn();
|
||||
const mockStripeAccountsRetrieve = jest.fn();
|
||||
const mockStripeAccountLinksCreate = jest.fn();
|
||||
const mockStripeTransfersCreate = jest.fn();
|
||||
const mockStripeRefundsCreate = jest.fn();
|
||||
const mockStripeRefundsRetrieve = jest.fn();
|
||||
const mockStripePaymentIntentsCreate = jest.fn();
|
||||
const mockStripeCustomersCreate = jest.fn();
|
||||
const mockStripeCheckoutSessionsCreate = jest.fn();
|
||||
|
||||
jest.mock('stripe', () => {
|
||||
return jest.fn(() => ({
|
||||
checkout: {
|
||||
sessions: {
|
||||
retrieve: mockStripeCheckoutSessionsRetrieve,
|
||||
create: mockStripeCheckoutSessionsCreate
|
||||
}
|
||||
},
|
||||
accounts: {
|
||||
create: mockStripeAccountsCreate,
|
||||
retrieve: mockStripeAccountsRetrieve
|
||||
},
|
||||
accountLinks: {
|
||||
create: mockStripeAccountLinksCreate
|
||||
},
|
||||
transfers: {
|
||||
create: mockStripeTransfersCreate
|
||||
},
|
||||
refunds: {
|
||||
create: mockStripeRefundsCreate,
|
||||
retrieve: mockStripeRefundsRetrieve
|
||||
},
|
||||
paymentIntents: {
|
||||
create: mockStripePaymentIntentsCreate
|
||||
},
|
||||
customers: {
|
||||
create: mockStripeCustomersCreate
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
const StripeService = require('../../../services/stripeService');
|
||||
|
||||
describe('StripeService', () => {
|
||||
let consoleSpy, consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
// Set environment variables for tests
|
||||
process.env.FRONTEND_URL = 'http://localhost:3000';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getCheckoutSession', () => {
|
||||
it('should retrieve checkout session successfully', async () => {
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
status: 'complete',
|
||||
setup_intent: {
|
||||
id: 'seti_123456789',
|
||||
payment_method: {
|
||||
id: 'pm_123456789',
|
||||
type: 'card'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeCheckoutSessionsRetrieve.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await StripeService.getCheckoutSession('cs_123456789');
|
||||
|
||||
expect(mockStripeCheckoutSessionsRetrieve).toHaveBeenCalledWith('cs_123456789', {
|
||||
expand: ['setup_intent', 'setup_intent.payment_method']
|
||||
});
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should handle checkout session retrieval errors', async () => {
|
||||
const stripeError = new Error('Session not found');
|
||||
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.getCheckoutSession('invalid_session'))
|
||||
.rejects.toThrow('Session not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving checkout session:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing session ID', async () => {
|
||||
const stripeError = new Error('Invalid session ID');
|
||||
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.getCheckoutSession(null))
|
||||
.rejects.toThrow('Invalid session ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConnectedAccount', () => {
|
||||
it('should create connected account with default country', async () => {
|
||||
const mockAccount = {
|
||||
id: 'acct_123456789',
|
||||
type: 'express',
|
||||
email: 'test@example.com',
|
||||
country: 'US',
|
||||
capabilities: {
|
||||
transfers: { status: 'pending' }
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await StripeService.createConnectedAccount({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
|
||||
type: 'express',
|
||||
email: 'test@example.com',
|
||||
country: 'US',
|
||||
capabilities: {
|
||||
transfers: { requested: true }
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(mockAccount);
|
||||
});
|
||||
|
||||
it('should create connected account with custom country', async () => {
|
||||
const mockAccount = {
|
||||
id: 'acct_123456789',
|
||||
type: 'express',
|
||||
email: 'test@example.com',
|
||||
country: 'CA',
|
||||
capabilities: {
|
||||
transfers: { status: 'pending' }
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await StripeService.createConnectedAccount({
|
||||
email: 'test@example.com',
|
||||
country: 'CA'
|
||||
});
|
||||
|
||||
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
|
||||
type: 'express',
|
||||
email: 'test@example.com',
|
||||
country: 'CA',
|
||||
capabilities: {
|
||||
transfers: { requested: true }
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(mockAccount);
|
||||
});
|
||||
|
||||
it('should handle connected account creation errors', async () => {
|
||||
const stripeError = new Error('Invalid email address');
|
||||
mockStripeAccountsCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createConnectedAccount({
|
||||
email: 'invalid-email'
|
||||
})).rejects.toThrow('Invalid email address');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating connected account:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing email parameter', async () => {
|
||||
const stripeError = new Error('Email is required');
|
||||
mockStripeAccountsCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createConnectedAccount({}))
|
||||
.rejects.toThrow('Email is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAccountLink', () => {
|
||||
it('should create account link successfully', async () => {
|
||||
const mockAccountLink = {
|
||||
object: 'account_link',
|
||||
url: 'https://connect.stripe.com/setup/e/acct_123456789',
|
||||
created: Date.now(),
|
||||
expires_at: Date.now() + 3600
|
||||
};
|
||||
|
||||
mockStripeAccountLinksCreate.mockResolvedValue(mockAccountLink);
|
||||
|
||||
const result = await StripeService.createAccountLink(
|
||||
'acct_123456789',
|
||||
'http://localhost:3000/refresh',
|
||||
'http://localhost:3000/return'
|
||||
);
|
||||
|
||||
expect(mockStripeAccountLinksCreate).toHaveBeenCalledWith({
|
||||
account: 'acct_123456789',
|
||||
refresh_url: 'http://localhost:3000/refresh',
|
||||
return_url: 'http://localhost:3000/return',
|
||||
type: 'account_onboarding'
|
||||
});
|
||||
expect(result).toEqual(mockAccountLink);
|
||||
});
|
||||
|
||||
it('should handle account link creation errors', async () => {
|
||||
const stripeError = new Error('Account not found');
|
||||
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createAccountLink(
|
||||
'invalid_account',
|
||||
'http://localhost:3000/refresh',
|
||||
'http://localhost:3000/return'
|
||||
)).rejects.toThrow('Account not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating account link:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs', async () => {
|
||||
const stripeError = new Error('Invalid URL format');
|
||||
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createAccountLink(
|
||||
'acct_123456789',
|
||||
'invalid-url',
|
||||
'invalid-url'
|
||||
)).rejects.toThrow('Invalid URL format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountStatus', () => {
|
||||
it('should retrieve account status successfully', async () => {
|
||||
const mockAccount = {
|
||||
id: 'acct_123456789',
|
||||
details_submitted: true,
|
||||
payouts_enabled: true,
|
||||
capabilities: {
|
||||
transfers: { status: 'active' }
|
||||
},
|
||||
requirements: {
|
||||
pending_verification: [],
|
||||
currently_due: [],
|
||||
past_due: []
|
||||
},
|
||||
other_field: 'should_be_filtered_out'
|
||||
};
|
||||
|
||||
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await StripeService.getAccountStatus('acct_123456789');
|
||||
|
||||
expect(mockStripeAccountsRetrieve).toHaveBeenCalledWith('acct_123456789');
|
||||
expect(result).toEqual({
|
||||
id: 'acct_123456789',
|
||||
details_submitted: true,
|
||||
payouts_enabled: true,
|
||||
capabilities: {
|
||||
transfers: { status: 'active' }
|
||||
},
|
||||
requirements: {
|
||||
pending_verification: [],
|
||||
currently_due: [],
|
||||
past_due: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle account status retrieval errors', async () => {
|
||||
const stripeError = new Error('Account not found');
|
||||
mockStripeAccountsRetrieve.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.getAccountStatus('invalid_account'))
|
||||
.rejects.toThrow('Account not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving account status:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle accounts with incomplete data', async () => {
|
||||
const mockAccount = {
|
||||
id: 'acct_123456789',
|
||||
details_submitted: false,
|
||||
payouts_enabled: false,
|
||||
capabilities: null,
|
||||
requirements: null
|
||||
};
|
||||
|
||||
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await StripeService.getAccountStatus('acct_123456789');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'acct_123456789',
|
||||
details_submitted: false,
|
||||
payouts_enabled: false,
|
||||
capabilities: null,
|
||||
requirements: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTransfer', () => {
|
||||
it('should create transfer with default currency', async () => {
|
||||
const mockTransfer = {
|
||||
id: 'tr_123456789',
|
||||
amount: 5000, // $50.00 in cents
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {
|
||||
rentalId: '1',
|
||||
ownerId: '2'
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||
|
||||
const result = await StripeService.createTransfer({
|
||||
amount: 50.00,
|
||||
destination: 'acct_123456789',
|
||||
metadata: {
|
||||
rentalId: '1',
|
||||
ownerId: '2'
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||
amount: 5000, // Converted to cents
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {
|
||||
rentalId: '1',
|
||||
ownerId: '2'
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(mockTransfer);
|
||||
});
|
||||
|
||||
it('should create transfer with custom currency', async () => {
|
||||
const mockTransfer = {
|
||||
id: 'tr_123456789',
|
||||
amount: 5000,
|
||||
currency: 'eur',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||
|
||||
const result = await StripeService.createTransfer({
|
||||
amount: 50.00,
|
||||
currency: 'eur',
|
||||
destination: 'acct_123456789'
|
||||
});
|
||||
|
||||
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||
amount: 5000,
|
||||
currency: 'eur',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
});
|
||||
expect(result).toEqual(mockTransfer);
|
||||
});
|
||||
|
||||
it('should handle decimal amounts correctly', async () => {
|
||||
const mockTransfer = {
|
||||
id: 'tr_123456789',
|
||||
amount: 12534, // $125.34 in cents
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||
|
||||
await StripeService.createTransfer({
|
||||
amount: 125.34,
|
||||
destination: 'acct_123456789'
|
||||
});
|
||||
|
||||
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||
amount: 12534, // Properly converted to cents
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle transfer creation errors', async () => {
|
||||
const stripeError = new Error('Insufficient funds');
|
||||
mockStripeTransfersCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createTransfer({
|
||||
amount: 50.00,
|
||||
destination: 'acct_123456789'
|
||||
})).rejects.toThrow('Insufficient funds');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating transfer:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rounding for very small amounts', async () => {
|
||||
const mockTransfer = {
|
||||
id: 'tr_123456789',
|
||||
amount: 1, // $0.005 rounded to 1 cent
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||
|
||||
await StripeService.createTransfer({
|
||||
amount: 0.005, // Should round to 1 cent
|
||||
destination: 'acct_123456789'
|
||||
});
|
||||
|
||||
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||
amount: 1,
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRefund', () => {
|
||||
it('should create refund with default parameters', async () => {
|
||||
const mockRefund = {
|
||||
id: 're_123456789',
|
||||
amount: 5000, // $50.00 in cents
|
||||
payment_intent: 'pi_123456789',
|
||||
reason: 'requested_by_customer',
|
||||
status: 'succeeded',
|
||||
metadata: {
|
||||
rentalId: '1'
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||
|
||||
const result = await StripeService.createRefund({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 50.00,
|
||||
metadata: {
|
||||
rentalId: '1'
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||
payment_intent: 'pi_123456789',
|
||||
amount: 5000, // Converted to cents
|
||||
metadata: {
|
||||
rentalId: '1'
|
||||
},
|
||||
reason: 'requested_by_customer'
|
||||
});
|
||||
expect(result).toEqual(mockRefund);
|
||||
});
|
||||
|
||||
it('should create refund with custom reason', async () => {
|
||||
const mockRefund = {
|
||||
id: 're_123456789',
|
||||
amount: 10000,
|
||||
payment_intent: 'pi_123456789',
|
||||
reason: 'fraudulent',
|
||||
status: 'succeeded',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||
|
||||
const result = await StripeService.createRefund({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 100.00,
|
||||
reason: 'fraudulent'
|
||||
});
|
||||
|
||||
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||
payment_intent: 'pi_123456789',
|
||||
amount: 10000,
|
||||
metadata: {},
|
||||
reason: 'fraudulent'
|
||||
});
|
||||
expect(result).toEqual(mockRefund);
|
||||
});
|
||||
|
||||
it('should handle decimal amounts correctly', async () => {
|
||||
const mockRefund = {
|
||||
id: 're_123456789',
|
||||
amount: 12534, // $125.34 in cents
|
||||
payment_intent: 'pi_123456789',
|
||||
reason: 'requested_by_customer',
|
||||
status: 'succeeded',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||
|
||||
await StripeService.createRefund({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 125.34
|
||||
});
|
||||
|
||||
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||
payment_intent: 'pi_123456789',
|
||||
amount: 12534, // Properly converted to cents
|
||||
metadata: {},
|
||||
reason: 'requested_by_customer'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refund creation errors', async () => {
|
||||
const stripeError = new Error('Payment intent not found');
|
||||
mockStripeRefundsCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createRefund({
|
||||
paymentIntentId: 'pi_invalid',
|
||||
amount: 50.00
|
||||
})).rejects.toThrow('Payment intent not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating refund:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle partial refund scenarios', async () => {
|
||||
const mockRefund = {
|
||||
id: 're_123456789',
|
||||
amount: 2500, // Partial refund of $25.00
|
||||
payment_intent: 'pi_123456789',
|
||||
reason: 'requested_by_customer',
|
||||
status: 'succeeded',
|
||||
metadata: {
|
||||
type: 'partial'
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||
|
||||
const result = await StripeService.createRefund({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 25.00,
|
||||
metadata: {
|
||||
type: 'partial'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(2500);
|
||||
expect(result.metadata.type).toBe('partial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefund', () => {
|
||||
it('should retrieve refund successfully', async () => {
|
||||
const mockRefund = {
|
||||
id: 're_123456789',
|
||||
amount: 5000,
|
||||
payment_intent: 'pi_123456789',
|
||||
reason: 'requested_by_customer',
|
||||
status: 'succeeded',
|
||||
created: Date.now()
|
||||
};
|
||||
|
||||
mockStripeRefundsRetrieve.mockResolvedValue(mockRefund);
|
||||
|
||||
const result = await StripeService.getRefund('re_123456789');
|
||||
|
||||
expect(mockStripeRefundsRetrieve).toHaveBeenCalledWith('re_123456789');
|
||||
expect(result).toEqual(mockRefund);
|
||||
});
|
||||
|
||||
it('should handle refund retrieval errors', async () => {
|
||||
const stripeError = new Error('Refund not found');
|
||||
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.getRefund('re_invalid'))
|
||||
.rejects.toThrow('Refund not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving refund:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle null refund ID', async () => {
|
||||
const stripeError = new Error('Invalid refund ID');
|
||||
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.getRefund(null))
|
||||
.rejects.toThrow('Invalid refund ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chargePaymentMethod', () => {
|
||||
it('should charge payment method successfully', async () => {
|
||||
const mockPaymentIntent = {
|
||||
id: 'pi_123456789',
|
||||
status: 'succeeded',
|
||||
client_secret: 'pi_123456789_secret_test',
|
||||
amount: 5000,
|
||||
currency: 'usd'
|
||||
};
|
||||
|
||||
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await StripeService.chargePaymentMethod(
|
||||
'pm_123456789',
|
||||
50.00,
|
||||
'cus_123456789',
|
||||
{ rentalId: '1' }
|
||||
);
|
||||
|
||||
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith({
|
||||
amount: 5000, // Converted to cents
|
||||
currency: 'usd',
|
||||
payment_method: 'pm_123456789',
|
||||
customer: 'cus_123456789',
|
||||
confirm: true,
|
||||
return_url: 'http://localhost:3000/payment-complete',
|
||||
metadata: { rentalId: '1' }
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
status: 'succeeded',
|
||||
clientSecret: 'pi_123456789_secret_test'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle payment method charge errors', async () => {
|
||||
const stripeError = new Error('Payment method declined');
|
||||
mockStripePaymentIntentsCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.chargePaymentMethod(
|
||||
'pm_invalid',
|
||||
50.00,
|
||||
'cus_123456789'
|
||||
)).rejects.toThrow('Payment method declined');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error charging payment method:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default frontend URL when not set', async () => {
|
||||
delete process.env.FRONTEND_URL;
|
||||
|
||||
const mockPaymentIntent = {
|
||||
id: 'pi_123456789',
|
||||
status: 'succeeded',
|
||||
client_secret: 'pi_123456789_secret_test'
|
||||
};
|
||||
|
||||
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
await StripeService.chargePaymentMethod(
|
||||
'pm_123456789',
|
||||
50.00,
|
||||
'cus_123456789'
|
||||
);
|
||||
|
||||
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
return_url: 'http://localhost:3000/payment-complete'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle decimal amounts correctly', async () => {
|
||||
const mockPaymentIntent = {
|
||||
id: 'pi_123456789',
|
||||
status: 'succeeded',
|
||||
client_secret: 'pi_123456789_secret_test'
|
||||
};
|
||||
|
||||
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
await StripeService.chargePaymentMethod(
|
||||
'pm_123456789',
|
||||
125.34,
|
||||
'cus_123456789'
|
||||
);
|
||||
|
||||
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
amount: 12534 // Properly converted to cents
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle payment requiring authentication', async () => {
|
||||
const mockPaymentIntent = {
|
||||
id: 'pi_123456789',
|
||||
status: 'requires_action',
|
||||
client_secret: 'pi_123456789_secret_test',
|
||||
next_action: {
|
||||
type: 'use_stripe_sdk'
|
||||
}
|
||||
};
|
||||
|
||||
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await StripeService.chargePaymentMethod(
|
||||
'pm_123456789',
|
||||
50.00,
|
||||
'cus_123456789'
|
||||
);
|
||||
|
||||
expect(result.status).toBe('requires_action');
|
||||
expect(result.clientSecret).toBe('pi_123456789_secret_test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomer', () => {
|
||||
it('should create customer successfully', async () => {
|
||||
const mockCustomer = {
|
||||
id: 'cus_123456789',
|
||||
email: 'test@example.com',
|
||||
name: 'John Doe',
|
||||
metadata: {
|
||||
userId: '123'
|
||||
},
|
||||
created: Date.now()
|
||||
};
|
||||
|
||||
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await StripeService.createCustomer({
|
||||
email: 'test@example.com',
|
||||
name: 'John Doe',
|
||||
metadata: {
|
||||
userId: '123'
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
name: 'John Doe',
|
||||
metadata: {
|
||||
userId: '123'
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
|
||||
it('should create customer with minimal data', async () => {
|
||||
const mockCustomer = {
|
||||
id: 'cus_123456789',
|
||||
email: 'test@example.com',
|
||||
name: null,
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await StripeService.createCustomer({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
name: undefined,
|
||||
metadata: {}
|
||||
});
|
||||
expect(result).toEqual(mockCustomer);
|
||||
});
|
||||
|
||||
it('should handle customer creation errors', async () => {
|
||||
const stripeError = new Error('Invalid email format');
|
||||
mockStripeCustomersCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createCustomer({
|
||||
email: 'invalid-email'
|
||||
})).rejects.toThrow('Invalid email format');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating customer:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle duplicate customer errors', async () => {
|
||||
const stripeError = new Error('Customer already exists');
|
||||
mockStripeCustomersCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createCustomer({
|
||||
email: 'existing@example.com',
|
||||
name: 'Existing User'
|
||||
})).rejects.toThrow('Customer already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSetupCheckoutSession', () => {
|
||||
it('should create setup checkout session successfully', async () => {
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
url: null,
|
||||
client_secret: 'cs_123456789_secret_test',
|
||||
customer: 'cus_123456789',
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
userId: '123'
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await StripeService.createSetupCheckoutSession({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {
|
||||
userId: '123'
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
|
||||
customer: 'cus_123456789',
|
||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
userId: '123'
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should create setup checkout session with minimal data', async () => {
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
url: null,
|
||||
client_secret: 'cs_123456789_secret_test',
|
||||
customer: 'cus_123456789',
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
metadata: {
|
||||
type: 'payment_method_setup'
|
||||
}
|
||||
};
|
||||
|
||||
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await StripeService.createSetupCheckoutSession({
|
||||
customerId: 'cus_123456789'
|
||||
});
|
||||
|
||||
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
|
||||
customer: 'cus_123456789',
|
||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
metadata: {
|
||||
type: 'payment_method_setup'
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should handle setup checkout session creation errors', async () => {
|
||||
const stripeError = new Error('Customer not found');
|
||||
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createSetupCheckoutSession({
|
||||
customerId: 'cus_invalid'
|
||||
})).rejects.toThrow('Customer not found');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating setup checkout session:',
|
||||
stripeError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing customer ID', async () => {
|
||||
const stripeError = new Error('Customer ID is required');
|
||||
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
|
||||
|
||||
await expect(StripeService.createSetupCheckoutSession({}))
|
||||
.rejects.toThrow('Customer ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling and edge cases', () => {
|
||||
it('should handle very large monetary amounts', async () => {
|
||||
const mockTransfer = {
|
||||
id: 'tr_123456789',
|
||||
amount: 99999999, // $999,999.99 in cents
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
||||
|
||||
await StripeService.createTransfer({
|
||||
amount: 999999.99,
|
||||
destination: 'acct_123456789'
|
||||
});
|
||||
|
||||
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
|
||||
amount: 99999999,
|
||||
currency: 'usd',
|
||||
destination: 'acct_123456789',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero amounts', async () => {
|
||||
const mockRefund = {
|
||||
id: 're_123456789',
|
||||
amount: 0,
|
||||
payment_intent: 'pi_123456789',
|
||||
reason: 'requested_by_customer',
|
||||
status: 'succeeded',
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
||||
|
||||
await StripeService.createRefund({
|
||||
paymentIntentId: 'pi_123456789',
|
||||
amount: 0
|
||||
});
|
||||
|
||||
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
|
||||
payment_intent: 'pi_123456789',
|
||||
amount: 0,
|
||||
metadata: {},
|
||||
reason: 'requested_by_customer'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle network timeout errors', async () => {
|
||||
const timeoutError = new Error('Request timeout');
|
||||
timeoutError.type = 'StripeConnectionError';
|
||||
mockStripeTransfersCreate.mockRejectedValue(timeoutError);
|
||||
|
||||
await expect(StripeService.createTransfer({
|
||||
amount: 50.00,
|
||||
destination: 'acct_123456789'
|
||||
})).rejects.toThrow('Request timeout');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating transfer:',
|
||||
timeoutError
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API key errors', async () => {
|
||||
const apiKeyError = new Error('Invalid API key');
|
||||
apiKeyError.type = 'StripeAuthenticationError';
|
||||
mockStripeCustomersCreate.mockRejectedValue(apiKeyError);
|
||||
|
||||
await expect(StripeService.createCustomer({
|
||||
email: 'test@example.com'
|
||||
})).rejects.toThrow('Invalid API key');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating customer:',
|
||||
apiKeyError
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user