backend unit tests

This commit is contained in:
jackiettran
2025-09-19 19:46:41 -04:00
parent cf6dd9be90
commit 649289bf90
28 changed files with 17266 additions and 57 deletions

13
backend/tests/setup.js Normal file
View 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()
};

View 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'
});
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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');
});
});
});

File diff suppressed because it is too large Load Diff

View 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');
});
});
});

View 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
});
});
});
});

File diff suppressed because it is too large Load Diff

View 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);
});
});
});

View 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
});
});
});
});

View 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' });
});
});
});

View 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);
});
});
});

View 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'
});
});
});
});

View 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');
});
});
});

View 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);
});
});
});

View 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();
});
});
});

View 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
);
});
});
});