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

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