backend unit tests
This commit is contained in:
682
backend/tests/unit/routes/auth.test.js
Normal file
682
backend/tests/unit/routes/auth.test.js
Normal file
@@ -0,0 +1,682 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('google-auth-library');
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
or: 'or'
|
||||
}
|
||||
}));
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findByPk: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock middleware
|
||||
jest.mock('../../../middleware/validation', () => ({
|
||||
sanitizeInput: (req, res, next) => next(),
|
||||
validateRegistration: (req, res, next) => next(),
|
||||
validateLogin: (req, res, next) => next(),
|
||||
validateGoogleAuth: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/csrf', () => ({
|
||||
csrfProtection: (req, res, next) => next(),
|
||||
getCSRFToken: (req, res) => res.json({ csrfToken: 'test-csrf-token' })
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/rateLimiter', () => ({
|
||||
loginLimiter: (req, res, next) => next(),
|
||||
registerLimiter: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
const { User } = require('../../../models');
|
||||
|
||||
// Set up OAuth2Client mock before requiring authRoutes
|
||||
const mockGoogleClient = {
|
||||
verifyIdToken: jest.fn()
|
||||
};
|
||||
OAuth2Client.mockImplementation(() => mockGoogleClient);
|
||||
|
||||
const authRoutes = require('../../../routes/auth');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/auth', authRoutes);
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset environment
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.GOOGLE_CLIENT_ID = 'test-google-client-id';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Reset JWT mock to return different tokens for each call
|
||||
let tokenCallCount = 0;
|
||||
jwt.sign.mockImplementation(() => {
|
||||
tokenCallCount++;
|
||||
return tokenCallCount === 1 ? 'access-token' : 'refresh-token';
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/csrf-token', () => {
|
||||
it('should return CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/auth/csrf-token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('csrfToken');
|
||||
expect(response.body.csrfToken).toBe('test-csrf-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('should register a new user successfully', async () => {
|
||||
User.findOne.mockResolvedValue(null); // No existing user
|
||||
|
||||
const newUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
|
||||
User.create.mockResolvedValue(newUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
phone: '1234567890'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.user).toEqual({
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
// Check that cookies are set
|
||||
expect(response.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('accessToken'),
|
||||
expect.stringContaining('refreshToken')
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject registration with existing email', async () => {
|
||||
User.findOne.mockResolvedValue({ id: 1, email: 'test@example.com' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Registration failed');
|
||||
expect(response.body.details[0].message).toBe('An account with this email already exists');
|
||||
});
|
||||
|
||||
it('should reject registration with existing username', async () => {
|
||||
User.findOne.mockResolvedValue({ id: 1, username: 'testuser' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Registration failed');
|
||||
});
|
||||
|
||||
it('should handle registration errors', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
User.create.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'StrongPass123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Registration failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('should login user with valid credentials', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
isLocked: jest.fn().mockReturnValue(false),
|
||||
comparePassword: jest.fn().mockResolvedValue(true),
|
||||
resetLoginAttempts: jest.fn().mockResolvedValue()
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual({
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
expect(mockUser.resetLoginAttempts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject login with invalid email', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should reject login with invalid password', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
isLocked: jest.fn().mockReturnValue(false),
|
||||
comparePassword: jest.fn().mockResolvedValue(false),
|
||||
incLoginAttempts: jest.fn().mockResolvedValue()
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid credentials');
|
||||
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject login for locked account', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
isLocked: jest.fn().mockReturnValue(true)
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(423);
|
||||
expect(response.body.error).toContain('Account is temporarily locked');
|
||||
});
|
||||
|
||||
it('should handle login errors', async () => {
|
||||
User.findOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Login failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/google', () => {
|
||||
it('should handle Google OAuth login for new user', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@gmail.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User',
|
||||
picture: 'profile.jpg'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
User.findOne
|
||||
.mockResolvedValueOnce(null) // No existing Google user
|
||||
.mockResolvedValueOnce(null); // No existing email user
|
||||
|
||||
const newUser = {
|
||||
id: 1,
|
||||
username: 'test_gle123',
|
||||
email: 'test@gmail.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
profileImage: 'profile.jpg'
|
||||
};
|
||||
|
||||
User.create.mockResolvedValue(newUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-google-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual(newUser);
|
||||
expect(User.create).toHaveBeenCalledWith({
|
||||
email: 'test@gmail.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
authProvider: 'google',
|
||||
providerId: 'google123',
|
||||
profileImage: 'profile.jpg',
|
||||
username: 'test_gle123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Google OAuth login for existing user', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@gmail.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
const existingUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@gmail.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
|
||||
User.findOne.mockResolvedValue(existingUser);
|
||||
jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-google-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual(existingUser);
|
||||
});
|
||||
|
||||
it('should reject when email exists with different auth provider', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@example.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
User.findOne
|
||||
.mockResolvedValueOnce(null) // No Google user
|
||||
.mockResolvedValueOnce({ id: 1, email: 'test@example.com' }); // Existing email user
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-google-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.error).toContain('An account with this email already exists');
|
||||
});
|
||||
|
||||
it('should reject missing ID token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('ID token is required');
|
||||
});
|
||||
|
||||
it('should handle expired Google token', async () => {
|
||||
const error = new Error('Token used too late');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'expired-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Google token has expired. Please try again.');
|
||||
});
|
||||
|
||||
it('should handle invalid Google token', async () => {
|
||||
const error = new Error('Invalid token signature');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'invalid-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid Google token. Please try again.');
|
||||
});
|
||||
|
||||
it('should handle malformed Google token', async () => {
|
||||
const error = new Error('Wrong number of segments in token');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'malformed.token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Malformed Google token. Please try again.');
|
||||
});
|
||||
|
||||
it('should handle missing required user information', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123',
|
||||
email: 'test@gmail.com',
|
||||
// Missing given_name and family_name
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'valid-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Required user information not provided by Google');
|
||||
});
|
||||
|
||||
it('should handle unexpected Google auth errors', async () => {
|
||||
const unexpectedError = new Error('Unexpected Google error');
|
||||
mockGoogleClient.verifyIdToken.mockRejectedValue(unexpectedError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/google')
|
||||
.send({
|
||||
idToken: 'error-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Google authentication failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
it('should refresh access token with valid refresh token', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
};
|
||||
|
||||
jwt.verify.mockReturnValue({ id: 1, type: 'refresh' });
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
jwt.sign.mockReturnValue('new-access-token');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=valid-refresh-token']);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual(mockUser);
|
||||
expect(response.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('accessToken=new-access-token')
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject missing refresh token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Refresh token required');
|
||||
});
|
||||
|
||||
it('should reject invalid refresh token', async () => {
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=invalid-token']);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid or expired refresh token');
|
||||
});
|
||||
|
||||
it('should reject non-refresh token type', async () => {
|
||||
jwt.verify.mockReturnValue({ id: 1, type: 'access' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=access-token']);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Invalid refresh token');
|
||||
});
|
||||
|
||||
it('should reject refresh token for non-existent user', async () => {
|
||||
jwt.verify.mockReturnValue({ id: 999, type: 'refresh' });
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', ['refreshToken=valid-token']);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should logout user and clear cookies', async () => {
|
||||
const response = await request(app)
|
||||
.post('/auth/logout');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
|
||||
// Check that cookies are cleared
|
||||
expect(response.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('accessToken=;'),
|
||||
expect.stringContaining('refreshToken=;')
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security features', () => {
|
||||
it('should set secure cookies in production', async () => {
|
||||
process.env.NODE_ENV = 'prod';
|
||||
|
||||
User.findOne.mockResolvedValue(null);
|
||||
const newUser = { id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' };
|
||||
User.create.mockResolvedValue(newUser);
|
||||
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
// In production, cookies should have secure flag
|
||||
expect(response.headers['set-cookie'][0]).toContain('Secure');
|
||||
});
|
||||
|
||||
it('should generate unique username for Google users', async () => {
|
||||
const mockPayload = {
|
||||
sub: 'google123456',
|
||||
email: 'test@gmail.com',
|
||||
given_name: 'Test',
|
||||
family_name: 'User'
|
||||
};
|
||||
|
||||
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => mockPayload
|
||||
});
|
||||
|
||||
User.findOne
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
User.create.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'test_123456',
|
||||
email: 'test@gmail.com'
|
||||
});
|
||||
|
||||
jwt.sign.mockReturnValueOnce('token').mockReturnValueOnce('refresh');
|
||||
|
||||
await request(app)
|
||||
.post('/auth/google')
|
||||
.send({ idToken: 'valid-token' });
|
||||
|
||||
expect(User.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'test_123456' // email prefix + last 6 chars of Google ID
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token management', () => {
|
||||
it('should generate both access and refresh tokens on registration', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
|
||||
|
||||
jwt.sign
|
||||
.mockReturnValueOnce('access-token')
|
||||
.mockReturnValueOnce('refresh-token');
|
||||
|
||||
await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
{ id: 1 },
|
||||
'test-secret',
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
{ id: 1, type: 'refresh' },
|
||||
'test-secret',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should set correct cookie options', async () => {
|
||||
User.findOne.mockResolvedValue(null);
|
||||
User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' });
|
||||
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
firstName: 'Test',
|
||||
lastName: 'User'
|
||||
});
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
expect(cookies[0]).toContain('HttpOnly');
|
||||
expect(cookies[0]).toContain('SameSite=Strict');
|
||||
expect(cookies[1]).toContain('HttpOnly');
|
||||
expect(cookies[1]).toContain('SameSite=Strict');
|
||||
});
|
||||
});
|
||||
});
|
||||
823
backend/tests/unit/routes/itemRequests.test.js
Normal file
823
backend/tests/unit/routes/itemRequests.test.js
Normal file
@@ -0,0 +1,823 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const itemRequestsRouter = require('../../../routes/itemRequests');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
ItemRequest: {
|
||||
findAndCountAll: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
ItemRequestResponse: {
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
User: jest.fn(),
|
||||
Item: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
or: Symbol('or'),
|
||||
iLike: Symbol('iLike'),
|
||||
},
|
||||
}));
|
||||
|
||||
const { ItemRequest, ItemRequestResponse, User, Item } = require('../../../models');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/item-requests', itemRequestsRouter);
|
||||
|
||||
// Mock models
|
||||
const mockItemRequestFindAndCountAll = ItemRequest.findAndCountAll;
|
||||
const mockItemRequestFindAll = ItemRequest.findAll;
|
||||
const mockItemRequestFindByPk = ItemRequest.findByPk;
|
||||
const mockItemRequestCreate = ItemRequest.create;
|
||||
const mockItemRequestResponseFindByPk = ItemRequestResponse.findByPk;
|
||||
const mockItemRequestResponseCreate = ItemRequestResponse.create;
|
||||
|
||||
describe('ItemRequests Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should get item requests with default pagination and status', async () => {
|
||||
const mockRequestsData = {
|
||||
count: 25,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Need a Camera',
|
||||
description: 'Looking for a DSLR camera for weekend photography',
|
||||
status: 'open',
|
||||
requesterId: 2,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
requester: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Power Drill Needed',
|
||||
description: 'Need a drill for home improvement project',
|
||||
status: 'open',
|
||||
requesterId: 3,
|
||||
createdAt: '2024-01-14T10:00:00.000Z',
|
||||
requester: {
|
||||
id: 3,
|
||||
username: 'bob_smith',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Smith'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockRequestsData);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
requests: mockRequestsData.rows,
|
||||
totalPages: 2,
|
||||
currentPage: 1,
|
||||
totalRequests: 25
|
||||
});
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: { status: 'open' },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'requester',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
}
|
||||
],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter requests with search query', async () => {
|
||||
const mockSearchResults = {
|
||||
count: 5,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Need a Camera',
|
||||
description: 'Looking for a DSLR camera',
|
||||
status: 'open'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests?search=camera&page=1&limit=10');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'open',
|
||||
[Op.or]: [
|
||||
{ title: { [Op.iLike]: '%camera%' } },
|
||||
{ description: { [Op.iLike]: '%camera%' } }
|
||||
]
|
||||
},
|
||||
include: expect.any(Array),
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom pagination', async () => {
|
||||
const mockData = { count: 50, rows: [] };
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests?page=3&limit=5');
|
||||
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: { status: 'open' },
|
||||
include: expect.any(Array),
|
||||
limit: 5,
|
||||
offset: 10, // (3-1) * 5
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by custom status', async () => {
|
||||
const mockData = { count: 10, rows: [] };
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
||||
|
||||
await request(app)
|
||||
.get('/item-requests?status=fulfilled');
|
||||
|
||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
||||
where: { status: 'fulfilled' },
|
||||
include: expect.any(Array),
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindAndCountAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /my-requests', () => {
|
||||
it('should get user\'s own requests with responses', async () => {
|
||||
const mockRequests = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'My Camera Request',
|
||||
description: 'Need a camera',
|
||||
status: 'open',
|
||||
requesterId: 1,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
id: 1,
|
||||
message: 'I have a Canon DSLR available',
|
||||
responder: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: {
|
||||
id: 5,
|
||||
name: 'Canon EOS 5D',
|
||||
description: 'Professional DSLR camera'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
mockItemRequestFindAll.mockResolvedValue(mockRequests);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/my-requests');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRequests);
|
||||
expect(mockItemRequestFindAll).toHaveBeenCalledWith({
|
||||
where: { requesterId: 1 },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'requester',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: ItemRequestResponse,
|
||||
as: 'responses',
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'responder',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: Item,
|
||||
as: 'existingItem'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/my-requests');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('should get specific request with responses', async () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
title: 'Camera Request',
|
||||
description: 'Need a DSLR camera',
|
||||
status: 'open',
|
||||
requesterId: 2,
|
||||
requester: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
id: 1,
|
||||
message: 'I have a Canon DSLR',
|
||||
responder: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: null
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRequest);
|
||||
expect(mockItemRequestFindByPk).toHaveBeenCalledWith('1', {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'requester',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: ItemRequestResponse,
|
||||
as: 'responses',
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'responder',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
},
|
||||
{
|
||||
model: Item,
|
||||
as: 'existingItem'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('should create a new item request', async () => {
|
||||
const requestData = {
|
||||
title: 'Need a Drill',
|
||||
description: 'Looking for a power drill for weekend project',
|
||||
category: 'tools',
|
||||
budget: 50,
|
||||
location: 'New York'
|
||||
};
|
||||
|
||||
const mockCreatedRequest = {
|
||||
id: 3,
|
||||
...requestData,
|
||||
requesterId: 1,
|
||||
status: 'open'
|
||||
};
|
||||
|
||||
const mockRequestWithRequester = {
|
||||
...mockCreatedRequest,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
};
|
||||
|
||||
mockItemRequestCreate.mockResolvedValue(mockCreatedRequest);
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequestWithRequester);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests')
|
||||
.send(requestData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockRequestWithRequester);
|
||||
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
||||
...requestData,
|
||||
requesterId: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockItemRequestCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests')
|
||||
.send({
|
||||
title: 'Test Request',
|
||||
description: 'Test description'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
title: 'Original Title',
|
||||
requesterId: 1,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
});
|
||||
|
||||
it('should update item request for owner', async () => {
|
||||
const updateData = {
|
||||
title: 'Updated Title',
|
||||
description: 'Updated description'
|
||||
};
|
||||
|
||||
const mockUpdatedRequest = {
|
||||
...mockRequest,
|
||||
...updateData,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.update.mockResolvedValue();
|
||||
mockItemRequestFindByPk
|
||||
.mockResolvedValueOnce(mockRequest)
|
||||
.mockResolvedValueOnce(mockUpdatedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/1')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
title: 'Updated Title',
|
||||
description: 'Updated description',
|
||||
requesterId: 1,
|
||||
requester: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
}
|
||||
});
|
||||
expect(mockRequest.update).toHaveBeenCalledWith(updateData);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/999')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
||||
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/1')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/1')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
requesterId: 1,
|
||||
destroy: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
});
|
||||
|
||||
it('should delete item request for owner', async () => {
|
||||
mockRequest.destroy.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockRequest.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
||||
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/item-requests/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/responses', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
requesterId: 2,
|
||||
status: 'open',
|
||||
increment: jest.fn()
|
||||
};
|
||||
|
||||
const mockResponseData = {
|
||||
message: 'I have a drill you can borrow',
|
||||
price: 25,
|
||||
existingItemId: 5
|
||||
};
|
||||
|
||||
const mockCreatedResponse = {
|
||||
id: 1,
|
||||
...mockResponseData,
|
||||
itemRequestId: 1,
|
||||
responderId: 1
|
||||
};
|
||||
|
||||
const mockResponseWithDetails = {
|
||||
...mockCreatedResponse,
|
||||
responder: {
|
||||
id: 1,
|
||||
username: 'john_doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: {
|
||||
id: 5,
|
||||
name: 'Power Drill',
|
||||
description: 'Cordless power drill'
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
||||
mockItemRequestResponseCreate.mockResolvedValue(mockCreatedResponse);
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponseWithDetails);
|
||||
});
|
||||
|
||||
it('should create a response to item request', async () => {
|
||||
mockRequest.increment.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockResponseWithDetails);
|
||||
expect(mockItemRequestResponseCreate).toHaveBeenCalledWith({
|
||||
...mockResponseData,
|
||||
itemRequestId: '1',
|
||||
responderId: 1
|
||||
});
|
||||
expect(mockRequest.increment).toHaveBeenCalledWith('responseCount');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent request', async () => {
|
||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/999/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
||||
});
|
||||
|
||||
it('should prevent responding to own request', async () => {
|
||||
const ownRequest = { ...mockRequest, requesterId: 1 };
|
||||
mockItemRequestFindByPk.mockResolvedValue(ownRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot respond to your own request' });
|
||||
});
|
||||
|
||||
it('should prevent responding to closed request', async () => {
|
||||
const closedRequest = { ...mockRequest, status: 'fulfilled' };
|
||||
mockItemRequestFindByPk.mockResolvedValue(closedRequest);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot respond to closed request' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestResponseCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests/1/responses')
|
||||
.send(mockResponseData);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /responses/:responseId/status', () => {
|
||||
const mockResponse = {
|
||||
id: 1,
|
||||
status: 'pending',
|
||||
itemRequest: {
|
||||
id: 1,
|
||||
requesterId: 1
|
||||
},
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponse);
|
||||
});
|
||||
|
||||
it('should update response status to accepted and fulfill request', async () => {
|
||||
const updatedResponse = {
|
||||
...mockResponse,
|
||||
status: 'accepted',
|
||||
responder: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: null
|
||||
};
|
||||
|
||||
mockResponse.update.mockResolvedValue();
|
||||
mockResponse.itemRequest.update = jest.fn().mockResolvedValue();
|
||||
mockItemRequestResponseFindByPk
|
||||
.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(updatedResponse);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
status: 'accepted',
|
||||
itemRequest: {
|
||||
id: 1,
|
||||
requesterId: 1
|
||||
},
|
||||
responder: {
|
||||
id: 2,
|
||||
username: 'jane_doe',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe'
|
||||
},
|
||||
existingItem: null
|
||||
});
|
||||
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'accepted' });
|
||||
expect(mockResponse.itemRequest.update).toHaveBeenCalledWith({ status: 'fulfilled' });
|
||||
});
|
||||
|
||||
it('should update response status without fulfilling request', async () => {
|
||||
const updatedResponse = { ...mockResponse, status: 'declined' };
|
||||
mockResponse.update.mockResolvedValue();
|
||||
mockItemRequestResponseFindByPk
|
||||
.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(updatedResponse);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'declined' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'declined' });
|
||||
expect(mockResponse.itemRequest.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent response', async () => {
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/999/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Response not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedResponse = {
|
||||
...mockResponse,
|
||||
itemRequest: { ...mockResponse.itemRequest, requesterId: 2 }
|
||||
};
|
||||
mockItemRequestResponseFindByPk.mockResolvedValue(unauthorizedResponse);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Only the requester can update response status' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemRequestResponseFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/item-requests/responses/1/status')
|
||||
.send({ status: 'accepted' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty search results', async () => {
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests?search=nonexistent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.requests).toEqual([]);
|
||||
expect(response.body.totalRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle zero page calculation', async () => {
|
||||
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/item-requests');
|
||||
|
||||
expect(response.body.totalPages).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle request without optional fields', async () => {
|
||||
const minimalRequest = {
|
||||
title: 'Basic Request',
|
||||
description: 'Simple description'
|
||||
};
|
||||
|
||||
const mockCreated = { id: 1, ...minimalRequest, requesterId: 1 };
|
||||
const mockWithRequester = {
|
||||
...mockCreated,
|
||||
requester: { id: 1, username: 'test' }
|
||||
};
|
||||
|
||||
mockItemRequestCreate.mockResolvedValue(mockCreated);
|
||||
mockItemRequestFindByPk.mockResolvedValue(mockWithRequester);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/item-requests')
|
||||
.send(minimalRequest);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
||||
...minimalRequest,
|
||||
requesterId: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1026
backend/tests/unit/routes/items.test.js
Normal file
1026
backend/tests/unit/routes/items.test.js
Normal file
File diff suppressed because it is too large
Load Diff
726
backend/tests/unit/routes/maps.test.js
Normal file
726
backend/tests/unit/routes/maps.test.js
Normal file
@@ -0,0 +1,726 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../services/googleMapsService', () => ({
|
||||
getPlacesAutocomplete: jest.fn(),
|
||||
getPlaceDetails: jest.fn(),
|
||||
geocodeAddress: jest.fn(),
|
||||
isConfigured: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: (req, res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock rate limiter middleware
|
||||
jest.mock('../../../middleware/rateLimiter', () => ({
|
||||
burstProtection: (req, res, next) => next(),
|
||||
placesAutocomplete: (req, res, next) => next(),
|
||||
placeDetails: (req, res, next) => next(),
|
||||
geocoding: (req, res, next) => next()
|
||||
}));
|
||||
|
||||
const googleMapsService = require('../../../services/googleMapsService');
|
||||
const mapsRoutes = require('../../../routes/maps');
|
||||
|
||||
// Set up Express app for testing
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/maps', mapsRoutes);
|
||||
|
||||
describe('Maps Routes', () => {
|
||||
let consoleSpy, consoleErrorSpy, consoleLogSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Input Validation Middleware', () => {
|
||||
it('should trim and validate input length', async () => {
|
||||
const longInput = 'a'.repeat(501);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: longInput });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Input too long' });
|
||||
});
|
||||
|
||||
it('should validate place ID format', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'invalid@place#id!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Invalid place ID format' });
|
||||
});
|
||||
|
||||
it('should validate address length', async () => {
|
||||
const longAddress = 'a'.repeat(501);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: longAddress });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Address too long' });
|
||||
});
|
||||
|
||||
it('should allow valid place ID format', async () => {
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue({
|
||||
result: { name: 'Test Place' }
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc_DEF' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should trim whitespace from inputs', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({
|
||||
predictions: []
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: ' test input ' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'test input',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Middleware', () => {
|
||||
it('should handle API key configuration errors', async () => {
|
||||
const configError = new Error('API key not configured');
|
||||
googleMapsService.getPlacesAutocomplete.mockRejectedValue(configError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Maps service temporarily unavailable',
|
||||
details: 'Configuration issue'
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Maps service error:',
|
||||
'API key not configured'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle quota exceeded errors', async () => {
|
||||
const quotaError = new Error('quota exceeded');
|
||||
googleMapsService.getPlacesAutocomplete.mockRejectedValue(quotaError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Service temporarily unavailable due to high demand',
|
||||
details: 'Please try again later'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generic service errors', async () => {
|
||||
const serviceError = new Error('Network timeout');
|
||||
googleMapsService.getPlacesAutocomplete.mockRejectedValue(serviceError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to process request',
|
||||
details: 'Network timeout'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /places/autocomplete', () => {
|
||||
const mockPredictions = {
|
||||
predictions: [
|
||||
{
|
||||
description: '123 Main St, New York, NY, USA',
|
||||
place_id: 'ChIJ123abc',
|
||||
types: ['street_address']
|
||||
},
|
||||
{
|
||||
description: '456 Oak Ave, New York, NY, USA',
|
||||
place_id: 'ChIJ456def',
|
||||
types: ['street_address']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it('should return autocomplete predictions successfully', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
input: '123 Main',
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'us' },
|
||||
sessionToken: 'session123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPredictions);
|
||||
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'123 Main',
|
||||
{
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'us' },
|
||||
sessionToken: 'session123'
|
||||
}
|
||||
);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete: user=1, query_length=8, results=2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default types when not provided', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'test',
|
||||
{
|
||||
types: ['address'],
|
||||
componentRestrictions: undefined,
|
||||
sessionToken: undefined
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty predictions for short input', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'a' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty predictions for missing input', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'No token provided' });
|
||||
});
|
||||
|
||||
it('should log request with user ID from authenticated user', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should log with user ID
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('user=1')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty predictions from service', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'nonexistent place' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete: user=1, query_length=17, results=0'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service response without predictions array', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Places Autocomplete: user=1, query_length=4, results=0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /places/details', () => {
|
||||
const mockPlaceDetails = {
|
||||
result: {
|
||||
place_id: 'ChIJ123abc',
|
||||
name: 'Central Park',
|
||||
formatted_address: 'New York, NY 10024, USA',
|
||||
geometry: {
|
||||
location: { lat: 40.785091, lng: -73.968285 }
|
||||
},
|
||||
types: ['park', 'point_of_interest']
|
||||
}
|
||||
};
|
||||
|
||||
it('should return place details successfully', async () => {
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
placeId: 'ChIJ123abc',
|
||||
sessionToken: 'session123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPlaceDetails);
|
||||
|
||||
expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith(
|
||||
'ChIJ123abc',
|
||||
{ sessionToken: 'session123' }
|
||||
);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Place Details: user=1, placeId=ChIJ123abc...'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle place details without session token', async () => {
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith(
|
||||
'ChIJ123abc',
|
||||
{ sessionToken: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for missing place ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Place ID is required' });
|
||||
expect(googleMapsService.getPlaceDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for empty place ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Place ID is required' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.send({ placeId: 'ChIJ123abc' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle very long place IDs in logging', async () => {
|
||||
const longPlaceId = 'ChIJ' + 'a'.repeat(100);
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: longPlaceId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`Place Details: user=1, placeId=${longPlaceId.substring(0, 10)}...`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const serviceError = new Error('Place not found');
|
||||
googleMapsService.getPlaceDetails.mockRejectedValue(serviceError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to process request',
|
||||
details: 'Place not found'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /geocode', () => {
|
||||
const mockGeocodeResults = {
|
||||
results: [
|
||||
{
|
||||
formatted_address: '123 Main St, New York, NY 10001, USA',
|
||||
geometry: {
|
||||
location: { lat: 40.7484405, lng: -73.9856644 }
|
||||
},
|
||||
place_id: 'ChIJ123abc',
|
||||
types: ['street_address']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it('should return geocoding results successfully', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
address: '123 Main St, New York, NY',
|
||||
componentRestrictions: { country: 'US' }
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockGeocodeResults);
|
||||
|
||||
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St, New York, NY',
|
||||
{ componentRestrictions: { country: 'US' } }
|
||||
);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Geocoding: user=1, address_length=25'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle geocoding without component restrictions', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: '123 Main St' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St',
|
||||
{ componentRestrictions: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for missing address', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Address is required' });
|
||||
expect(googleMapsService.geocodeAddress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for empty address', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Address is required' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.send({ address: '123 Main St' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle addresses with special characters', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: '123 Main St, Apt #4B' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith(
|
||||
'123 Main St, Apt #4B',
|
||||
{ componentRestrictions: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const serviceError = new Error('Invalid address');
|
||||
googleMapsService.geocodeAddress.mockRejectedValue(serviceError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: 'invalid address' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to process request',
|
||||
details: 'Invalid address'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty geocoding results', async () => {
|
||||
googleMapsService.geocodeAddress.mockResolvedValue({ results: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: 'nonexistent address' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ results: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('should return healthy status when service is configured', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'healthy',
|
||||
service: 'Google Maps API Proxy',
|
||||
timestamp: expect.any(String),
|
||||
configuration: {
|
||||
apiKeyConfigured: true
|
||||
}
|
||||
});
|
||||
|
||||
// Verify timestamp is a valid ISO string
|
||||
expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp);
|
||||
});
|
||||
|
||||
it('should return unavailable status when service is not configured', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(false);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toEqual({
|
||||
status: 'unavailable',
|
||||
service: 'Google Maps API Proxy',
|
||||
timestamp: expect.any(String),
|
||||
configuration: {
|
||||
apiKeyConfigured: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not require authentication', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should work without authorization header
|
||||
});
|
||||
|
||||
it('should always return current timestamp', async () => {
|
||||
googleMapsService.isConfigured.mockReturnValue(true);
|
||||
|
||||
const beforeTime = new Date().toISOString();
|
||||
const response = await request(app)
|
||||
.get('/maps/health');
|
||||
const afterTime = new Date().toISOString();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(new Date(response.body.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(beforeTime).getTime());
|
||||
expect(new Date(response.body.timestamp).getTime()).toBeLessThanOrEqual(new Date(afterTime).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting Integration', () => {
|
||||
it('should apply burst protection to all endpoints', async () => {
|
||||
// This test verifies that rate limiting middleware is applied
|
||||
// In a real scenario, we'd test actual rate limiting behavior
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The fact that the request succeeded means rate limiting middleware was applied without blocking
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Security', () => {
|
||||
it('should handle null input gracefully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: null });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ predictions: [] });
|
||||
});
|
||||
|
||||
it('should handle undefined values in request body', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
input: 'test',
|
||||
types: undefined,
|
||||
componentRestrictions: undefined,
|
||||
sessionToken: undefined
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
'test',
|
||||
{
|
||||
types: ['address'], // Should use default
|
||||
componentRestrictions: undefined,
|
||||
sessionToken: undefined
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('invalid json');
|
||||
|
||||
expect(response.status).toBe(400); // Express will handle malformed JSON
|
||||
});
|
||||
|
||||
it('should sanitize input to prevent injection attacks', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
|
||||
const maliciousInput = '<script>alert("xss")</script>';
|
||||
const response = await request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: maliciousInput });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Input should be treated as string and passed through
|
||||
expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith(
|
||||
maliciousInput,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle concurrent requests to different endpoints', async () => {
|
||||
googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] });
|
||||
googleMapsService.getPlaceDetails.mockResolvedValue({ result: {} });
|
||||
googleMapsService.geocodeAddress.mockResolvedValue({ results: [] });
|
||||
|
||||
const [response1, response2, response3] = await Promise.all([
|
||||
request(app)
|
||||
.post('/maps/places/autocomplete')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ input: 'test1' }),
|
||||
request(app)
|
||||
.post('/maps/places/details')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ placeId: 'ChIJ123abc' }),
|
||||
request(app)
|
||||
.post('/maps/geocode')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ address: 'test address' })
|
||||
]);
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
expect(response3.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
657
backend/tests/unit/routes/messages.test.js
Normal file
657
backend/tests/unit/routes/messages.test.js
Normal file
@@ -0,0 +1,657 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const messagesRouter = require('../../../routes/messages');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
Message: {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('sequelize', () => ({
|
||||
Op: {
|
||||
or: Symbol('or'),
|
||||
},
|
||||
}));
|
||||
|
||||
const { Message, User } = require('../../../models');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/messages', messagesRouter);
|
||||
|
||||
// Mock models
|
||||
const mockMessageFindAll = Message.findAll;
|
||||
const mockMessageFindOne = Message.findOne;
|
||||
const mockMessageFindByPk = Message.findByPk;
|
||||
const mockMessageCreate = Message.create;
|
||||
const mockMessageCount = Message.count;
|
||||
const mockUserFindByPk = User.findByPk;
|
||||
|
||||
describe('Messages Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should get inbox messages for authenticated user', async () => {
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
senderId: 3,
|
||||
receiverId: 1,
|
||||
subject: 'Another Message',
|
||||
content: 'Hi!',
|
||||
isRead: true,
|
||||
createdAt: '2024-01-14T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 3,
|
||||
firstName: 'Bob',
|
||||
lastName: 'Johnson',
|
||||
profileImage: null
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
mockMessageFindAll.mockResolvedValue(mockMessages);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockMessages);
|
||||
expect(mockMessageFindAll).toHaveBeenCalledWith({
|
||||
where: { receiverId: 1 },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /sent', () => {
|
||||
it('should get sent messages for authenticated user', async () => {
|
||||
const mockSentMessages = [
|
||||
{
|
||||
id: 3,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'My Message',
|
||||
content: 'Hello Jane!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T12:00:00.000Z',
|
||||
receiver: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
mockMessageFindAll.mockResolvedValue(mockSentMessages);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/sent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockSentMessages);
|
||||
expect(mockMessageFindAll).toHaveBeenCalledWith({
|
||||
where: { senderId: 1 },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/sent');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
const mockMessage = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
},
|
||||
receiver: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
],
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||
});
|
||||
|
||||
it('should get message with replies for receiver', async () => {
|
||||
mockMessage.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
},
|
||||
receiver: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||
});
|
||||
|
||||
it('should get message without marking as read for sender', async () => {
|
||||
const senderMessage = { ...mockMessage, senderId: 1, receiverId: 2 };
|
||||
mockMessageFindOne.mockResolvedValue(senderMessage);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
sender: {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
profileImage: 'jane.jpg'
|
||||
},
|
||||
receiver: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not mark already read message as read', async () => {
|
||||
const readMessage = { ...mockMessage, isRead: true };
|
||||
mockMessageFindOne.mockResolvedValue(readMessage);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent message', async () => {
|
||||
mockMessageFindOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Message not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
const mockReceiver = {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
email: 'jane@example.com'
|
||||
};
|
||||
|
||||
const mockCreatedMessage = {
|
||||
id: 5,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
};
|
||||
|
||||
const mockMessageWithSender = {
|
||||
...mockCreatedMessage,
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||
});
|
||||
|
||||
it('should create a new message', async () => {
|
||||
const messageData = {
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send(messageData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockMessageWithSender);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a reply message with parentMessageId', async () => {
|
||||
const replyData = {
|
||||
receiverId: 2,
|
||||
subject: 'Re: Original Message',
|
||||
content: 'This is a reply',
|
||||
parentMessageId: 1
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send(replyData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'Re: Original Message',
|
||||
content: 'This is a reply',
|
||||
parentMessageId: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent receiver', async () => {
|
||||
mockUserFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 999,
|
||||
subject: 'Test',
|
||||
content: 'Test message'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Receiver not found' });
|
||||
});
|
||||
|
||||
it('should prevent sending messages to self', async () => {
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 1, // Same as sender ID
|
||||
subject: 'Self Message',
|
||||
content: 'Hello self!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot send messages to yourself' });
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockMessageCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 2,
|
||||
subject: 'Test',
|
||||
content: 'Test message'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/read', () => {
|
||||
const mockMessage = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
isRead: false,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||
});
|
||||
|
||||
it('should mark message as read', async () => {
|
||||
const updatedMessage = { ...mockMessage, isRead: true };
|
||||
mockMessage.update.mockResolvedValue(updatedMessage);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/1/read');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
isRead: false
|
||||
});
|
||||
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||
expect(mockMessageFindOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: '1',
|
||||
receiverId: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent message', async () => {
|
||||
mockMessageFindOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/999/read');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Message not found' });
|
||||
});
|
||||
|
||||
it('should return 404 when user is not the receiver', async () => {
|
||||
// Message exists but user is not the receiver (query will return null)
|
||||
mockMessageFindOne.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/1/read');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Message not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageFindOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/messages/1/read');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /unread/count', () => {
|
||||
it('should get unread message count for authenticated user', async () => {
|
||||
mockMessageCount.mockResolvedValue(5);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/unread/count');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ count: 5 });
|
||||
expect(mockMessageCount).toHaveBeenCalledWith({
|
||||
where: {
|
||||
receiverId: 1,
|
||||
isRead: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return count of 0 when no unread messages', async () => {
|
||||
mockMessageCount.mockResolvedValue(0);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/unread/count');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockMessageCount.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/unread/count');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message authorization', () => {
|
||||
it('should only find messages where user is sender or receiver', async () => {
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(mockMessageFindOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: '1',
|
||||
[Op.or]: [
|
||||
{ senderId: 1 },
|
||||
{ receiverId: 1 }
|
||||
]
|
||||
},
|
||||
include: expect.any(Array)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty inbox', async () => {
|
||||
mockMessageFindAll.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty sent messages', async () => {
|
||||
mockMessageFindAll.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/sent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle message with no replies', async () => {
|
||||
const messageWithoutReplies = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
replies: [],
|
||||
update: jest.fn()
|
||||
};
|
||||
mockMessageFindOne.mockResolvedValue(messageWithoutReplies);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.replies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields in message creation', async () => {
|
||||
const mockReceiver = { id: 2, firstName: 'Jane', lastName: 'Smith' };
|
||||
const mockCreatedMessage = {
|
||||
id: 6,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: undefined,
|
||||
content: 'Just content',
|
||||
parentMessageId: undefined
|
||||
};
|
||||
const mockMessageWithSender = {
|
||||
...mockCreatedMessage,
|
||||
sender: { id: 1, firstName: 'John', lastName: 'Doe' }
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 2,
|
||||
content: 'Just content'
|
||||
// subject and parentMessageId omitted
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: undefined,
|
||||
content: 'Just content',
|
||||
parentMessageId: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
896
backend/tests/unit/routes/rentals.test.js
Normal file
896
backend/tests/unit/routes/rentals.test.js
Normal file
@@ -0,0 +1,896 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const rentalsRouter = require('../../../routes/rentals');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
Rental: {
|
||||
findAll: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
Item: {
|
||||
findByPk: jest.fn(),
|
||||
},
|
||||
User: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/feeCalculator', () => ({
|
||||
calculateRentalFees: jest.fn(() => ({
|
||||
totalChargedAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
})),
|
||||
formatFeesForDisplay: jest.fn(() => ({
|
||||
baseAmount: '$100.00',
|
||||
platformFee: '$20.00',
|
||||
totalAmount: '$120.00',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/refundService', () => ({
|
||||
getRefundPreview: jest.fn(),
|
||||
processCancellation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/stripeService', () => ({
|
||||
chargePaymentMethod: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Rental, Item, User } = require('../../../models');
|
||||
const FeeCalculator = require('../../../utils/feeCalculator');
|
||||
const RefundService = require('../../../services/refundService');
|
||||
const StripeService = require('../../../services/stripeService');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/rentals', rentalsRouter);
|
||||
|
||||
// Mock models
|
||||
const mockRentalFindAll = Rental.findAll;
|
||||
const mockRentalFindByPk = Rental.findByPk;
|
||||
const mockRentalFindOne = Rental.findOne;
|
||||
const mockRentalCreate = Rental.create;
|
||||
|
||||
describe('Rentals Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /my-rentals', () => {
|
||||
it('should get rentals for authenticated user', async () => {
|
||||
const mockRentals = [
|
||||
{
|
||||
id: 1,
|
||||
renterId: 1,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
renterId: 1,
|
||||
item: { id: 2, name: 'Another Item' },
|
||||
owner: { id: 3, username: 'owner2', firstName: 'Jane', lastName: 'Smith' },
|
||||
},
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockRentals);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-rentals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRentals);
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: { renterId: 1 },
|
||||
include: [
|
||||
{ model: Item, as: 'item' },
|
||||
{
|
||||
model: User,
|
||||
as: 'owner',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName'],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-rentals');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to fetch rentals' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /my-listings', () => {
|
||||
it('should get listings for authenticated user', async () => {
|
||||
const mockListings = [
|
||||
{
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
item: { id: 1, name: 'My Item' },
|
||||
renter: { id: 2, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
},
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockListings);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-listings');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockListings);
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: { ownerId: 1 },
|
||||
include: [
|
||||
{ model: Item, as: 'item' },
|
||||
{
|
||||
model: User,
|
||||
as: 'renter',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName'],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/my-listings');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to fetch listings' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
renterId: 1,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
};
|
||||
|
||||
it('should get rental by ID for authorized user (renter)', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRental);
|
||||
});
|
||||
|
||||
it('should get rental by ID for authorized user (owner)', async () => {
|
||||
const ownerRental = { ...mockRental, ownerId: 1, renterId: 2 };
|
||||
mockRentalFindByPk.mockResolvedValue(ownerRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(ownerRental);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
|
||||
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized to view this rental' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to fetch rental' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
const mockItem = {
|
||||
id: 1,
|
||||
name: 'Test Item',
|
||||
ownerId: 2,
|
||||
availability: true,
|
||||
pricePerHour: 10,
|
||||
pricePerDay: 50,
|
||||
};
|
||||
|
||||
const mockCreatedRental = {
|
||||
id: 1,
|
||||
itemId: 1,
|
||||
renterId: 1,
|
||||
ownerId: 2,
|
||||
totalAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
const mockRentalWithDetails = {
|
||||
...mockCreatedRental,
|
||||
item: mockItem,
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
};
|
||||
|
||||
const rentalData = {
|
||||
itemId: 1,
|
||||
startDateTime: '2024-01-15T10:00:00.000Z',
|
||||
endDateTime: '2024-01-15T18:00:00.000Z',
|
||||
deliveryMethod: 'pickup',
|
||||
deliveryAddress: null,
|
||||
notes: 'Test rental',
|
||||
stripePaymentMethodId: 'pm_test123',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Item.findByPk.mockResolvedValue(mockItem);
|
||||
mockRentalFindOne.mockResolvedValue(null); // No overlapping rentals
|
||||
mockRentalCreate.mockResolvedValue(mockCreatedRental);
|
||||
mockRentalFindByPk.mockResolvedValue(mockRentalWithDetails);
|
||||
});
|
||||
|
||||
it('should create a new rental with hourly pricing', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockRentalWithDetails);
|
||||
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(80); // 8 hours * 10/hour
|
||||
});
|
||||
|
||||
it('should create a new rental with daily pricing', async () => {
|
||||
const dailyRentalData = {
|
||||
...rentalData,
|
||||
endDateTime: '2024-01-17T18:00:00.000Z', // 3 days
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(dailyRentalData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(150); // 3 days * 50/day
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
Item.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Item not found' });
|
||||
});
|
||||
|
||||
it('should return 400 for unavailable item', async () => {
|
||||
Item.findByPk.mockResolvedValue({ ...mockItem, availability: false });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Item is not available' });
|
||||
});
|
||||
|
||||
it('should return 400 for overlapping rental', async () => {
|
||||
mockRentalFindOne.mockResolvedValue({ id: 999 }); // Overlapping rental exists
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Item is already booked for these dates' });
|
||||
});
|
||||
|
||||
it('should return 400 when payment method is missing', async () => {
|
||||
const dataWithoutPayment = { ...rentalData };
|
||||
delete dataWithoutPayment.stripePaymentMethodId;
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(dataWithoutPayment);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Payment method is required' });
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockRentalCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals')
|
||||
.send(rentalData);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to create rental' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/status', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'pending',
|
||||
stripePaymentMethodId: 'pm_test123',
|
||||
totalAmount: 120,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
renter: {
|
||||
id: 2,
|
||||
username: 'renter1',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Johnson',
|
||||
stripeCustomerId: 'cus_test123'
|
||||
},
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should update rental status to confirmed without payment processing', async () => {
|
||||
const nonPendingRental = { ...mockRental, status: 'active' };
|
||||
mockRentalFindByPk.mockResolvedValueOnce(nonPendingRental);
|
||||
|
||||
const updatedRental = { ...nonPendingRental, status: 'confirmed' };
|
||||
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(nonPendingRental.update).toHaveBeenCalledWith({ status: 'confirmed' });
|
||||
});
|
||||
|
||||
it('should process payment when owner approves pending rental', async () => {
|
||||
// Use the original mockRental (status: 'pending') for this test
|
||||
mockRentalFindByPk.mockResolvedValueOnce(mockRental);
|
||||
|
||||
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||
paymentIntentId: 'pi_test123',
|
||||
});
|
||||
|
||||
const updatedRental = {
|
||||
...mockRental,
|
||||
status: 'confirmed',
|
||||
paymentStatus: 'paid',
|
||||
stripePaymentIntentId: 'pi_test123'
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(StripeService.chargePaymentMethod).toHaveBeenCalledWith(
|
||||
'pm_test123',
|
||||
120,
|
||||
'cus_test123',
|
||||
expect.objectContaining({
|
||||
rentalId: 1,
|
||||
itemName: 'Test Item',
|
||||
})
|
||||
);
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
status: 'confirmed',
|
||||
paymentStatus: 'paid',
|
||||
stripePaymentIntentId: 'pi_test123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 when renter has no Stripe customer ID', async () => {
|
||||
const rentalWithoutStripeCustomer = {
|
||||
...mockRental,
|
||||
renter: { ...mockRental.renter, stripeCustomerId: null }
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValue(rentalWithoutStripeCustomer);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Renter does not have a Stripe customer account'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle payment failure during approval', async () => {
|
||||
StripeService.chargePaymentMethod.mockRejectedValue(
|
||||
new Error('Payment failed')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Payment failed during approval',
|
||||
details: 'Payment failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
|
||||
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized to update this rental' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to update rental status' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/review-renter', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'completed',
|
||||
renterReviewSubmittedAt: null,
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should allow owner to review renter', async () => {
|
||||
const reviewData = {
|
||||
rating: 5,
|
||||
review: 'Great renter!',
|
||||
privateMessage: 'Thanks for taking care of my item',
|
||||
};
|
||||
|
||||
mockRental.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send(reviewData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
renterRating: 5,
|
||||
renterReview: 'Great renter!',
|
||||
renterReviewSubmittedAt: expect.any(Date),
|
||||
renterPrivateMessage: 'Thanks for taking care of my item',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for non-owner', async () => {
|
||||
const nonOwnerRental = { ...mockRental, ownerId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Only owners can review renters' });
|
||||
});
|
||||
|
||||
it('should return 400 for non-completed rental', async () => {
|
||||
const activeRental = { ...mockRental, status: 'active' };
|
||||
mockRentalFindByPk.mockResolvedValue(activeRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Can only review completed rentals' });
|
||||
});
|
||||
|
||||
it('should return 400 if review already submitted', async () => {
|
||||
const reviewedRental = {
|
||||
...mockRental,
|
||||
renterReviewSubmittedAt: new Date()
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValue(reviewedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Renter review already submitted' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-renter')
|
||||
.send({ rating: 5, review: 'Great!' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to submit review' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/review-item', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
renterId: 1,
|
||||
status: 'completed',
|
||||
itemReviewSubmittedAt: null,
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should allow renter to review item', async () => {
|
||||
const reviewData = {
|
||||
rating: 4,
|
||||
review: 'Good item!',
|
||||
privateMessage: 'Item was as described',
|
||||
};
|
||||
|
||||
mockRental.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-item')
|
||||
.send(reviewData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
itemRating: 4,
|
||||
itemReview: 'Good item!',
|
||||
itemReviewSubmittedAt: expect.any(Date),
|
||||
itemPrivateMessage: 'Item was as described',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 403 for non-renter', async () => {
|
||||
const nonRenterRental = { ...mockRental, renterId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-item')
|
||||
.send({ rating: 4, review: 'Good!' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Only renters can review items' });
|
||||
});
|
||||
|
||||
it('should return 400 if review already submitted', async () => {
|
||||
const reviewedRental = {
|
||||
...mockRental,
|
||||
itemReviewSubmittedAt: new Date()
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValue(reviewedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/review-item')
|
||||
.send({ rating: 4, review: 'Good!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Item review already submitted' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/mark-completed', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'active',
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should allow owner to mark rental as completed', async () => {
|
||||
const completedRental = { ...mockRental, status: 'completed' };
|
||||
mockRentalFindByPk
|
||||
.mockResolvedValueOnce(mockRental)
|
||||
.mockResolvedValueOnce(completedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-completed');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRental.update).toHaveBeenCalledWith({ status: 'completed' });
|
||||
});
|
||||
|
||||
it('should return 403 for non-owner', async () => {
|
||||
const nonOwnerRental = { ...mockRental, ownerId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-completed');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Only owners can mark rentals as completed'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid status', async () => {
|
||||
const pendingRental = { ...mockRental, status: 'pending' };
|
||||
mockRentalFindByPk.mockResolvedValue(pendingRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-completed');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Can only mark active or confirmed rentals as completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /calculate-fees', () => {
|
||||
it('should calculate fees for given amount', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/calculate-fees')
|
||||
.send({ totalAmount: 100 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
fees: {
|
||||
totalChargedAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
},
|
||||
display: {
|
||||
baseAmount: '$100.00',
|
||||
platformFee: '$20.00',
|
||||
totalAmount: '$120.00',
|
||||
},
|
||||
});
|
||||
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(100);
|
||||
expect(FeeCalculator.formatFeesForDisplay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid amount', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/calculate-fees')
|
||||
.send({ totalAmount: 0 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Valid base amount is required' });
|
||||
});
|
||||
|
||||
it('should handle calculation errors', async () => {
|
||||
FeeCalculator.calculateRentalFees.mockImplementation(() => {
|
||||
throw new Error('Calculation error');
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/calculate-fees')
|
||||
.send({ totalAmount: 100 });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to calculate fees' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /earnings/status', () => {
|
||||
it('should get earnings status for owner', async () => {
|
||||
const mockEarnings = [
|
||||
{
|
||||
id: 1,
|
||||
totalAmount: 120,
|
||||
platformFee: 20,
|
||||
payoutAmount: 100,
|
||||
payoutStatus: 'completed',
|
||||
payoutProcessedAt: '2024-01-15T10:00:00.000Z',
|
||||
stripeTransferId: 'tr_test123',
|
||||
item: { name: 'Test Item' },
|
||||
},
|
||||
];
|
||||
|
||||
mockRentalFindAll.mockResolvedValue(mockEarnings);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/earnings/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockEarnings);
|
||||
expect(mockRentalFindAll).toHaveBeenCalledWith({
|
||||
where: {
|
||||
ownerId: 1,
|
||||
status: 'completed',
|
||||
},
|
||||
attributes: [
|
||||
'id',
|
||||
'totalAmount',
|
||||
'platformFee',
|
||||
'payoutAmount',
|
||||
'payoutStatus',
|
||||
'payoutProcessedAt',
|
||||
'stripeTransferId',
|
||||
],
|
||||
include: [{ model: Item, as: 'item', attributes: ['name'] }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/earnings/status');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/refund-preview', () => {
|
||||
it('should get refund preview', async () => {
|
||||
const mockPreview = {
|
||||
refundAmount: 80,
|
||||
refundPercentage: 80,
|
||||
reason: 'Cancelled more than 24 hours before start',
|
||||
};
|
||||
|
||||
RefundService.getRefundPreview.mockResolvedValue(mockPreview);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/refund-preview');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPreview);
|
||||
expect(RefundService.getRefundPreview).toHaveBeenCalledWith('1', 1);
|
||||
});
|
||||
|
||||
it('should handle refund service errors', async () => {
|
||||
RefundService.getRefundPreview.mockRejectedValue(
|
||||
new Error('Rental not found')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/refund-preview');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Rental not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/cancel', () => {
|
||||
it('should cancel rental with refund', async () => {
|
||||
const mockResult = {
|
||||
rental: {
|
||||
id: 1,
|
||||
status: 'cancelled',
|
||||
},
|
||||
refund: {
|
||||
amount: 80,
|
||||
stripeRefundId: 'rf_test123',
|
||||
},
|
||||
};
|
||||
|
||||
const mockUpdatedRental = {
|
||||
id: 1,
|
||||
status: 'cancelled',
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
|
||||
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
|
||||
};
|
||||
|
||||
RefundService.processCancellation.mockResolvedValue(mockResult);
|
||||
mockRentalFindByPk.mockResolvedValue(mockUpdatedRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/cancel')
|
||||
.send({ reason: 'Change of plans' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
rental: mockUpdatedRental,
|
||||
refund: mockResult.refund,
|
||||
});
|
||||
expect(RefundService.processCancellation).toHaveBeenCalledWith(
|
||||
'1',
|
||||
1,
|
||||
'Change of plans'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle cancellation errors', async () => {
|
||||
RefundService.processCancellation.mockRejectedValue(
|
||||
new Error('Cannot cancel completed rental')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/cancel')
|
||||
.send({ reason: 'Change of plans' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Cannot cancel completed rental' });
|
||||
});
|
||||
});
|
||||
});
|
||||
805
backend/tests/unit/routes/stripe.test.js
Normal file
805
backend/tests/unit/routes/stripe.test.js
Normal file
@@ -0,0 +1,805 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findOne: jest.fn()
|
||||
},
|
||||
Item: {}
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/stripeService', () => ({
|
||||
getCheckoutSession: jest.fn(),
|
||||
createConnectedAccount: jest.fn(),
|
||||
createAccountLink: jest.fn(),
|
||||
getAccountStatus: jest.fn(),
|
||||
createCustomer: jest.fn(),
|
||||
createSetupCheckoutSession: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: (req, res, next) => {
|
||||
// Mock authenticated user
|
||||
if (req.headers.authorization) {
|
||||
req.user = { id: 1 };
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const { User } = require('../../../models');
|
||||
const StripeService = require('../../../services/stripeService');
|
||||
const stripeRoutes = require('../../../routes/stripe');
|
||||
|
||||
// Set up Express app for testing
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/stripe', stripeRoutes);
|
||||
|
||||
describe('Stripe Routes', () => {
|
||||
let consoleSpy, consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up console spies
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('GET /checkout-session/:sessionId', () => {
|
||||
it('should retrieve checkout session successfully', async () => {
|
||||
const mockSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_details: {
|
||||
email: 'test@example.com'
|
||||
},
|
||||
setup_intent: {
|
||||
id: 'seti_123456789',
|
||||
status: 'succeeded'
|
||||
},
|
||||
metadata: {
|
||||
userId: '1'
|
||||
}
|
||||
};
|
||||
|
||||
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/cs_123456789');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_email: 'test@example.com',
|
||||
setup_intent: {
|
||||
id: 'seti_123456789',
|
||||
status: 'succeeded'
|
||||
},
|
||||
metadata: {
|
||||
userId: '1'
|
||||
}
|
||||
});
|
||||
|
||||
expect(StripeService.getCheckoutSession).toHaveBeenCalledWith('cs_123456789');
|
||||
});
|
||||
|
||||
it('should handle missing customer_details gracefully', async () => {
|
||||
const mockSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_details: null,
|
||||
setup_intent: null,
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/cs_123456789');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer_email: undefined,
|
||||
setup_intent: null,
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle checkout session retrieval errors', async () => {
|
||||
const error = new Error('Session not found');
|
||||
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/invalid_session');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Session not found' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving checkout session:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing session ID', async () => {
|
||||
const error = new Error('Invalid session ID');
|
||||
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/checkout-session/');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /accounts', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
stripeConnectedAccountId: null,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser.update.mockReset();
|
||||
mockUser.stripeConnectedAccountId = null;
|
||||
});
|
||||
|
||||
it('should create connected account successfully', async () => {
|
||||
const mockAccount = {
|
||||
id: 'acct_123456789',
|
||||
email: 'test@example.com',
|
||||
country: 'US'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
stripeConnectedAccountId: 'acct_123456789',
|
||||
success: true
|
||||
});
|
||||
|
||||
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||
expect(StripeService.createConnectedAccount).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
country: 'US'
|
||||
});
|
||||
expect(mockUser.update).toHaveBeenCalledWith({
|
||||
stripeConnectedAccountId: 'acct_123456789'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'User not found' });
|
||||
expect(StripeService.createConnectedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if user already has connected account', async () => {
|
||||
const userWithAccount = {
|
||||
...mockUser,
|
||||
stripeConnectedAccountId: 'acct_existing'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithAccount);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'User already has a connected account' });
|
||||
expect(StripeService.createConnectedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'No token provided' });
|
||||
});
|
||||
|
||||
it('should handle Stripe account creation errors', async () => {
|
||||
const error = new Error('Invalid email address');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Invalid email address' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating connected account:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const mockAccount = { id: 'acct_123456789' };
|
||||
const dbError = new Error('Database update failed');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||
mockUser.update.mockRejectedValue(dbError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database update failed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /account-links', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: 'acct_123456789'
|
||||
};
|
||||
|
||||
it('should create account link successfully', async () => {
|
||||
const mockAccountLink = {
|
||||
url: 'https://connect.stripe.com/setup/e/acct_123456789',
|
||||
expires_at: Date.now() + 3600
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createAccountLink.mockResolvedValue(mockAccountLink);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
url: mockAccountLink.url,
|
||||
expiresAt: mockAccountLink.expires_at
|
||||
});
|
||||
|
||||
expect(StripeService.createAccountLink).toHaveBeenCalledWith(
|
||||
'acct_123456789',
|
||||
'http://localhost:3000/refresh',
|
||||
'http://localhost:3000/return'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if no connected account found', async () => {
|
||||
const userWithoutAccount = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: null
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithoutAccount);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
expect(StripeService.createAccountLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
});
|
||||
|
||||
it('should validate required URLs', async () => {
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh'
|
||||
// Missing returnUrl
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'refreshUrl and returnUrl are required' });
|
||||
expect(StripeService.createAccountLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate both URLs are provided', async () => {
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
// Missing refreshUrl
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'refreshUrl and returnUrl are required' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle Stripe account link creation errors', async () => {
|
||||
const error = new Error('Account not found');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createAccountLink.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/account-links')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({
|
||||
refreshUrl: 'http://localhost:3000/refresh',
|
||||
returnUrl: 'http://localhost:3000/return'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Account not found' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating account link:',
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /account-status', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: 'acct_123456789'
|
||||
};
|
||||
|
||||
it('should get account status successfully', async () => {
|
||||
const mockAccountStatus = {
|
||||
id: 'acct_123456789',
|
||||
details_submitted: true,
|
||||
payouts_enabled: true,
|
||||
capabilities: {
|
||||
transfers: { status: 'active' }
|
||||
},
|
||||
requirements: {
|
||||
pending_verification: [],
|
||||
currently_due: [],
|
||||
past_due: []
|
||||
}
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.getAccountStatus.mockResolvedValue(mockAccountStatus);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
accountId: 'acct_123456789',
|
||||
detailsSubmitted: true,
|
||||
payoutsEnabled: true,
|
||||
capabilities: {
|
||||
transfers: { status: 'active' }
|
||||
},
|
||||
requirements: {
|
||||
pending_verification: [],
|
||||
currently_due: [],
|
||||
past_due: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(StripeService.getAccountStatus).toHaveBeenCalledWith('acct_123456789');
|
||||
});
|
||||
|
||||
it('should return error if no connected account found', async () => {
|
||||
const userWithoutAccount = {
|
||||
id: 1,
|
||||
stripeConnectedAccountId: null
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithoutAccount);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
expect(StripeService.getAccountStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No connected account found' });
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle Stripe account status retrieval errors', async () => {
|
||||
const error = new Error('Account not found');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.getAccountStatus.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/stripe/account-status')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Account not found' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error getting account status:',
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /create-setup-checkout-session', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
stripeCustomerId: null,
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser.update.mockReset();
|
||||
mockUser.stripeCustomerId = null;
|
||||
});
|
||||
|
||||
it('should create setup checkout session for new customer', async () => {
|
||||
const mockCustomer = {
|
||||
id: 'cus_123456789',
|
||||
email: 'test@example.com'
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
const rentalData = {
|
||||
itemId: '123',
|
||||
startDate: '2023-12-01',
|
||||
endDate: '2023-12-03'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ rentalData });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
clientSecret: 'cs_123456789_secret_test',
|
||||
sessionId: 'cs_123456789'
|
||||
});
|
||||
|
||||
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||
expect(StripeService.createCustomer).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
name: 'John Doe',
|
||||
metadata: {
|
||||
userId: '1'
|
||||
}
|
||||
});
|
||||
expect(mockUser.update).toHaveBeenCalledWith({ stripeCustomerId: 'cus_123456789' });
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {
|
||||
rentalData: JSON.stringify(rentalData)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use existing customer ID if available', async () => {
|
||||
const userWithCustomer = {
|
||||
...mockUser,
|
||||
stripeCustomerId: 'cus_existing123'
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(userWithCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
clientSecret: 'cs_123456789_secret_test',
|
||||
sessionId: 'cs_123456789'
|
||||
});
|
||||
|
||||
expect(StripeService.createCustomer).not.toHaveBeenCalled();
|
||||
expect(userWithCustomer.update).not.toHaveBeenCalled();
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_existing123',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle session without rental data', async () => {
|
||||
const mockCustomer = {
|
||||
id: 'cus_123456789'
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'User not found' });
|
||||
expect(StripeService.createCustomer).not.toHaveBeenCalled();
|
||||
expect(StripeService.createSetupCheckoutSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle customer creation errors', async () => {
|
||||
const error = new Error('Invalid email address');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Invalid email address' });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error creating setup checkout session:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
const mockCustomer = { id: 'cus_123456789' };
|
||||
const dbError = new Error('Database update failed');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
mockUser.update.mockRejectedValue(dbError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database update failed' });
|
||||
});
|
||||
|
||||
it('should handle session creation errors', async () => {
|
||||
const mockCustomer = { id: 'cus_123456789' };
|
||||
const sessionError = new Error('Session creation failed');
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
StripeService.createSetupCheckoutSession.mockRejectedValue(sessionError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Session creation failed' });
|
||||
});
|
||||
|
||||
it('should handle complex rental data', async () => {
|
||||
const mockCustomer = { id: 'cus_123456789' };
|
||||
const mockSession = {
|
||||
id: 'cs_123456789',
|
||||
client_secret: 'cs_123456789_secret_test'
|
||||
};
|
||||
|
||||
const complexRentalData = {
|
||||
itemId: '123',
|
||||
startDate: '2023-12-01',
|
||||
endDate: '2023-12-03',
|
||||
totalAmount: 150.00,
|
||||
additionalServices: ['cleaning', 'delivery'],
|
||||
notes: 'Special instructions'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createCustomer.mockResolvedValue(mockCustomer);
|
||||
StripeService.createSetupCheckoutSession.mockResolvedValue(mockSession);
|
||||
mockUser.update.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ rentalData: complexRentalData });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(StripeService.createSetupCheckoutSession).toHaveBeenCalledWith({
|
||||
customerId: 'cus_123456789',
|
||||
metadata: {
|
||||
rentalData: JSON.stringify(complexRentalData)
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling and edge cases', () => {
|
||||
it('should handle malformed JSON in rental data', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
stripeCustomerId: 'cus_123456789'
|
||||
};
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
// This should work fine as Express will parse valid JSON
|
||||
const response = await request(app)
|
||||
.post('/stripe/create-setup-checkout-session')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"rentalData":{"itemId":"123"}}');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle very large session IDs', async () => {
|
||||
const longSessionId = 'cs_' + 'a'.repeat(100);
|
||||
const error = new Error('Session ID too long');
|
||||
|
||||
StripeService.getCheckoutSession.mockRejectedValue(error);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/stripe/checkout-session/${longSessionId}`);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Session ID too long' });
|
||||
});
|
||||
|
||||
it('should handle concurrent requests for same user', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
stripeConnectedAccountId: null,
|
||||
update: jest.fn().mockResolvedValue({})
|
||||
};
|
||||
|
||||
const mockAccount = { id: 'acct_123456789' };
|
||||
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
StripeService.createConnectedAccount.mockResolvedValue(mockAccount);
|
||||
|
||||
// Simulate concurrent requests
|
||||
const [response1, response2] = await Promise.all([
|
||||
request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token'),
|
||||
request(app)
|
||||
.post('/stripe/accounts')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
]);
|
||||
|
||||
// Both should succeed (in this test scenario)
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
658
backend/tests/unit/routes/users.test.js
Normal file
658
backend/tests/unit/routes/users.test.js
Normal file
@@ -0,0 +1,658 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const usersRouter = require('../../../routes/users');
|
||||
|
||||
// Mock all dependencies
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
UserAddress: {
|
||||
findAll: jest.fn(),
|
||||
findByPk: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: jest.fn((req, res, next) => {
|
||||
req.user = {
|
||||
id: 1,
|
||||
update: jest.fn()
|
||||
};
|
||||
next();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/upload', () => ({
|
||||
uploadProfileImage: jest.fn((req, res, callback) => {
|
||||
// Mock successful upload
|
||||
req.file = {
|
||||
filename: 'test-profile.jpg'
|
||||
};
|
||||
callback(null);
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('path');
|
||||
const { User, UserAddress } = require('../../../models');
|
||||
const { uploadProfileImage } = require('../../../middleware/upload');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/users', usersRouter);
|
||||
|
||||
// Mock models
|
||||
const mockUserFindByPk = User.findByPk;
|
||||
const mockUserUpdate = User.update;
|
||||
const mockUserAddressFindAll = UserAddress.findAll;
|
||||
const mockUserAddressFindByPk = UserAddress.findByPk;
|
||||
const mockUserAddressCreate = UserAddress.create;
|
||||
|
||||
describe('Users Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /profile', () => {
|
||||
it('should get user profile for authenticated user', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '555-1234',
|
||||
profileImage: 'profile.jpg',
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/profile');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/profile');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /addresses', () => {
|
||||
it('should get user addresses', async () => {
|
||||
const mockAddresses = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
city: 'New York',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
address1: '456 Oak Ave',
|
||||
city: 'Boston',
|
||||
isPrimary: false,
|
||||
},
|
||||
];
|
||||
|
||||
mockUserAddressFindAll.mockResolvedValue(mockAddresses);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/addresses');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAddresses);
|
||||
expect(mockUserAddressFindAll).toHaveBeenCalledWith({
|
||||
where: { userId: 1 },
|
||||
order: [['isPrimary', 'DESC'], ['createdAt', 'ASC']]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserAddressFindAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/addresses');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /addresses', () => {
|
||||
it('should create a new address', async () => {
|
||||
const addressData = {
|
||||
address1: '789 Pine St',
|
||||
address2: 'Apt 4B',
|
||||
city: 'Chicago',
|
||||
state: 'IL',
|
||||
zipCode: '60601',
|
||||
country: 'USA',
|
||||
isPrimary: false,
|
||||
};
|
||||
|
||||
const mockCreatedAddress = {
|
||||
id: 3,
|
||||
...addressData,
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
mockUserAddressCreate.mockResolvedValue(mockCreatedAddress);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/addresses')
|
||||
.send(addressData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockCreatedAddress);
|
||||
expect(mockUserAddressCreate).toHaveBeenCalledWith({
|
||||
...addressData,
|
||||
userId: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors during creation', async () => {
|
||||
mockUserAddressCreate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/addresses')
|
||||
.send({
|
||||
address1: '789 Pine St',
|
||||
city: 'Chicago',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /addresses/:id', () => {
|
||||
const mockAddress = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
city: 'New York',
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||
});
|
||||
|
||||
it('should update user address', async () => {
|
||||
const updateData = {
|
||||
address1: '123 Updated St',
|
||||
city: 'Updated City',
|
||||
};
|
||||
|
||||
mockAddress.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/1')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
city: 'New York',
|
||||
});
|
||||
expect(mockAddress.update).toHaveBeenCalledWith(updateData);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent address', async () => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/999')
|
||||
.send({ address1: 'Updated St' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Address not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/1')
|
||||
.send({ address1: 'Updated St' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/addresses/1')
|
||||
.send({ address1: 'Updated St' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /addresses/:id', () => {
|
||||
const mockAddress = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
address1: '123 Main St',
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(mockAddress);
|
||||
});
|
||||
|
||||
it('should delete user address', async () => {
|
||||
mockAddress.destroy.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockAddress.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent address', async () => {
|
||||
mockUserAddressFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Address not found' });
|
||||
});
|
||||
|
||||
it('should return 403 for unauthorized user', async () => {
|
||||
const unauthorizedAddress = { ...mockAddress, userId: 2 };
|
||||
mockUserAddressFindByPk.mockResolvedValue(unauthorizedAddress);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/1');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserAddressFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/users/addresses/1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /availability', () => {
|
||||
it('should get user availability settings', async () => {
|
||||
const mockUser = {
|
||||
defaultAvailableAfter: '09:00',
|
||||
defaultAvailableBefore: '17:00',
|
||||
defaultSpecifyTimesPerDay: true,
|
||||
defaultWeeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/availability');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
generalAvailableAfter: '09:00',
|
||||
generalAvailableBefore: '17:00',
|
||||
specifyTimesPerDay: true,
|
||||
weeklyTimes: { monday: '09:00-17:00', tuesday: '10:00-16:00' },
|
||||
});
|
||||
expect(mockUserFindByPk).toHaveBeenCalledWith(1, {
|
||||
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/availability');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /availability', () => {
|
||||
it('should update user availability settings', async () => {
|
||||
const availabilityData = {
|
||||
generalAvailableAfter: '08:00',
|
||||
generalAvailableBefore: '18:00',
|
||||
specifyTimesPerDay: false,
|
||||
weeklyTimes: { monday: '08:00-18:00' },
|
||||
};
|
||||
|
||||
mockUserUpdate.mockResolvedValue([1]);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/availability')
|
||||
.send(availabilityData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Availability updated successfully' });
|
||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
||||
defaultAvailableAfter: '08:00',
|
||||
defaultAvailableBefore: '18:00',
|
||||
defaultSpecifyTimesPerDay: false,
|
||||
defaultWeeklyTimes: { monday: '08:00-18:00' },
|
||||
}, {
|
||||
where: { id: 1 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserUpdate.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/availability')
|
||||
.send({
|
||||
generalAvailableAfter: '08:00',
|
||||
generalAvailableBefore: '18:00',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('should get public user profile by ID', async () => {
|
||||
const mockUser = {
|
||||
id: 2,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
username: 'janesmith',
|
||||
profileImage: 'jane.jpg',
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/2');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockUserFindByPk).toHaveBeenCalledWith('2', {
|
||||
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
mockUserFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'User not found' });
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockUserFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/users/2');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /profile', () => {
|
||||
const mockUpdatedUser = {
|
||||
id: 1,
|
||||
firstName: 'Updated',
|
||||
lastName: 'User',
|
||||
email: 'updated@example.com',
|
||||
phone: '555-9999',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserFindByPk.mockResolvedValue(mockUpdatedUser);
|
||||
});
|
||||
|
||||
it('should update user profile', async () => {
|
||||
const profileData = {
|
||||
firstName: 'Updated',
|
||||
lastName: 'User',
|
||||
email: 'updated@example.com',
|
||||
phone: '555-9999',
|
||||
address1: '123 New St',
|
||||
city: 'New City',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send(profileData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedUser);
|
||||
});
|
||||
|
||||
it('should exclude empty email from update', async () => {
|
||||
const profileData = {
|
||||
firstName: 'Updated',
|
||||
lastName: 'User',
|
||||
email: '',
|
||||
phone: '555-9999',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send(profileData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Verify email was not included in the update call
|
||||
// (This would need to check the actual update call if we spy on req.user.update)
|
||||
});
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
const mockValidationError = new Error('Validation error');
|
||||
mockValidationError.errors = [
|
||||
{ path: 'email', message: 'Invalid email format' }
|
||||
];
|
||||
|
||||
// Mock req.user.update to throw validation error
|
||||
const { authenticateToken } = require('../../../middleware/auth');
|
||||
authenticateToken.mockImplementation((req, res, next) => {
|
||||
req.user = {
|
||||
id: 1,
|
||||
update: jest.fn().mockRejectedValue(mockValidationError)
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send({
|
||||
firstName: 'Test',
|
||||
email: 'invalid-email',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Validation error',
|
||||
details: [{ field: 'email', message: 'Invalid email format' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle general database errors', async () => {
|
||||
// Reset the authenticateToken mock to use default user
|
||||
const { authenticateToken } = require('../../../middleware/auth');
|
||||
authenticateToken.mockImplementation((req, res, next) => {
|
||||
req.user = {
|
||||
id: 1,
|
||||
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/users/profile')
|
||||
.send({
|
||||
firstName: 'Test',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /profile/image', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
profileImage: 'old-image.jpg',
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
it('should upload profile image successfully', async () => {
|
||||
mockUser.update.mockResolvedValue();
|
||||
fs.unlink.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Profile image uploaded successfully',
|
||||
filename: 'test-profile.jpg',
|
||||
imageUrl: '/uploads/profiles/test-profile.jpg'
|
||||
});
|
||||
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
|
||||
expect(mockUser.update).toHaveBeenCalledWith({
|
||||
profileImage: 'test-profile.jpg'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle upload errors', async () => {
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
callback(new Error('File too large'));
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'File too large' });
|
||||
});
|
||||
|
||||
it('should handle missing file', async () => {
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = null;
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'No file uploaded' });
|
||||
});
|
||||
|
||||
it('should handle database update errors', async () => {
|
||||
// Mock upload to succeed but database update to fail
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = { filename: 'test-profile.jpg' };
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const userWithError = {
|
||||
...mockUser,
|
||||
update: jest.fn().mockRejectedValue(new Error('Database error'))
|
||||
};
|
||||
mockUserFindByPk.mockResolvedValue(userWithError);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to update profile image' });
|
||||
});
|
||||
|
||||
it('should handle case when user has no existing profile image', async () => {
|
||||
// Mock upload to succeed
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = { filename: 'test-profile.jpg' };
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const userWithoutImage = {
|
||||
id: 1,
|
||||
profileImage: null,
|
||||
update: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mockUserFindByPk.mockResolvedValue(userWithoutImage);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fs.unlink).not.toHaveBeenCalled(); // No old image to delete
|
||||
});
|
||||
|
||||
it('should continue if old image deletion fails', async () => {
|
||||
// Mock upload to succeed
|
||||
uploadProfileImage.mockImplementation((req, res, callback) => {
|
||||
req.file = { filename: 'test-profile.jpg' };
|
||||
callback(null);
|
||||
});
|
||||
|
||||
const userWithImage = {
|
||||
id: 1,
|
||||
profileImage: 'old-image.jpg',
|
||||
update: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mockUserFindByPk.mockResolvedValue(userWithImage);
|
||||
fs.unlink.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/users/profile/image');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Profile image uploaded successfully',
|
||||
filename: 'test-profile.jpg',
|
||||
imageUrl: '/uploads/profiles/test-profile.jpg'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user