Files
rentall-app/backend/tests/unit/routes/items.test.js

1026 lines
28 KiB
JavaScript

const request = require('supertest');
const express = require('express');
// Mock Sequelize operators
jest.mock('sequelize', () => ({
Op: {
gte: 'gte',
lte: 'lte',
iLike: 'iLike',
or: 'or',
not: 'not'
}
}));
// Mock models
const mockItemFindAndCountAll = jest.fn();
const mockItemFindByPk = jest.fn();
const mockItemCreate = jest.fn();
const mockItemUpdate = jest.fn();
const mockItemDestroy = jest.fn();
const mockItemFindAll = jest.fn();
const mockRentalFindAll = jest.fn();
const mockUserModel = jest.fn();
jest.mock('../../../models', () => ({
Item: {
findAndCountAll: mockItemFindAndCountAll,
findByPk: mockItemFindByPk,
create: mockItemCreate,
findAll: mockItemFindAll
},
User: mockUserModel,
Rental: {
findAll: mockRentalFindAll
}
}));
// 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' });
}
}
}));
const { Item, User, Rental } = require('../../../models');
const { Op } = require('sequelize');
const itemsRoutes = require('../../../routes/items');
// Set up Express app for testing
const app = express();
app.use(express.json());
app.use('/items', itemsRoutes);
describe('Items Routes', () => {
let consoleSpy;
beforeEach(() => {
jest.clearAllMocks();
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
consoleSpy.mockRestore();
});
describe('GET /', () => {
const mockItems = [
{
id: 1,
name: 'Camping Tent',
description: 'Great for camping',
pricePerDay: 25.99,
city: 'New York',
zipCode: '10001',
latitude: 40.7484405,
longitude: -73.9856644,
createdAt: new Date('2023-01-01'),
toJSON: () => ({
id: 1,
name: 'Camping Tent',
description: 'Great for camping',
pricePerDay: 25.99,
city: 'New York',
zipCode: '10001',
latitude: 40.7484405,
longitude: -73.9856644,
createdAt: new Date('2023-01-01'),
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' }
})
},
{
id: 2,
name: 'Mountain Bike',
description: 'Perfect for trails',
pricePerDay: 35.50,
city: 'Los Angeles',
zipCode: '90210',
latitude: 34.0522265,
longitude: -118.2436596,
createdAt: new Date('2023-01-02'),
toJSON: () => ({
id: 2,
name: 'Mountain Bike',
description: 'Perfect for trails',
pricePerDay: 35.50,
city: 'Los Angeles',
zipCode: '90210',
latitude: 34.0522265,
longitude: -118.2436596,
createdAt: new Date('2023-01-02'),
owner: { id: 3, username: 'owner2', firstName: 'Jane', lastName: 'Smith' }
})
}
];
it('should get all items with default pagination', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 2,
rows: mockItems
});
const response = await request(app)
.get('/items');
expect(response.status).toBe(200);
expect(response.body).toEqual({
items: [
{
id: 1,
name: 'Camping Tent',
description: 'Great for camping',
pricePerDay: 25.99,
city: 'New York',
zipCode: '10001',
latitude: 40.75, // Rounded to 2 decimal places
longitude: -73.99, // Rounded to 2 decimal places
createdAt: mockItems[0].createdAt.toISOString(),
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' }
},
{
id: 2,
name: 'Mountain Bike',
description: 'Perfect for trails',
pricePerDay: 35.50,
city: 'Los Angeles',
zipCode: '90210',
latitude: 34.05, // Rounded to 2 decimal places
longitude: -118.24, // Rounded to 2 decimal places
createdAt: mockItems[1].createdAt.toISOString(),
owner: { id: 3, username: 'owner2', firstName: 'Jane', lastName: 'Smith' }
}
],
totalPages: 1,
currentPage: 1,
totalItems: 2
});
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {},
include: [
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
}
],
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should handle custom pagination', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 50,
rows: mockItems
});
const response = await request(app)
.get('/items?page=3&limit=10');
expect(response.status).toBe(200);
expect(response.body.totalPages).toBe(5);
expect(response.body.currentPage).toBe(3);
expect(response.body.totalItems).toBe(50);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {},
include: expect.any(Array),
limit: 10,
offset: 20, // (page 3 - 1) * limit 10
order: [['createdAt', 'DESC']]
});
});
it('should filter by price range', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[0]]
});
const response = await request(app)
.get('/items?minPrice=20&maxPrice=30');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
pricePerDay: {
gte: '20',
lte: '30'
}
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should filter by minimum price only', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[1]]
});
const response = await request(app)
.get('/items?minPrice=30');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
pricePerDay: {
gte: '30'
}
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should filter by maximum price only', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[0]]
});
const response = await request(app)
.get('/items?maxPrice=30');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
pricePerDay: {
lte: '30'
}
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should filter by city', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[0]]
});
const response = await request(app)
.get('/items?city=New York');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
city: { iLike: '%New York%' }
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should filter by zip code', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[0]]
});
const response = await request(app)
.get('/items?zipCode=10001');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
zipCode: { iLike: '%10001%' }
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should search by name and description', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[0]]
});
const response = await request(app)
.get('/items?search=camping');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
or: [
{ name: { iLike: '%camping%' } },
{ description: { iLike: '%camping%' } }
]
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should combine multiple filters', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [mockItems[0]]
});
const response = await request(app)
.get('/items?search=tent&city=New&minPrice=20&maxPrice=30');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
pricePerDay: {
gte: '20',
lte: '30'
},
city: { iLike: '%New%' },
or: [
{ name: { iLike: '%tent%' } },
{ description: { iLike: '%tent%' } }
]
},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
it('should handle coordinates rounding with null values', async () => {
const itemWithNullCoords = {
id: 3,
name: 'Test Item',
latitude: null,
longitude: null,
toJSON: () => ({
id: 3,
name: 'Test Item',
latitude: null,
longitude: null
})
};
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [itemWithNullCoords]
});
const response = await request(app)
.get('/items');
expect(response.status).toBe(200);
expect(response.body.items[0].latitude).toBeNull();
expect(response.body.items[0].longitude).toBeNull();
});
it('should handle database errors', async () => {
const dbError = new Error('Database connection failed');
mockItemFindAndCountAll.mockRejectedValue(dbError);
const response = await request(app)
.get('/items');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database connection failed' });
});
it('should return empty results when no items found', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 0,
rows: []
});
const response = await request(app)
.get('/items');
expect(response.status).toBe(200);
expect(response.body).toEqual({
items: [],
totalPages: 0,
currentPage: 1,
totalItems: 0
});
});
});
describe('GET /recommendations', () => {
const mockRecommendations = [
{ id: 1, name: 'Item 1', isAvailable: true },
{ id: 2, name: 'Item 2', isAvailable: true }
];
it('should get recommendations for authenticated user', async () => {
mockRentalFindAll.mockResolvedValue([]);
mockItemFindAll.mockResolvedValue(mockRecommendations);
const response = await request(app)
.get('/items/recommendations')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecommendations);
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: { renterId: 1 },
include: [{ model: Item, as: 'item' }]
});
expect(mockItemFindAll).toHaveBeenCalledWith({
where: { isAvailable: true, isDeleted: false },
limit: 10,
order: [['createdAt', 'DESC']]
});
});
it('should require authentication', async () => {
const response = await request(app)
.get('/items/recommendations');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'No token provided' });
});
it('should handle database errors', async () => {
const dbError = new Error('Database error');
mockRentalFindAll.mockRejectedValue(dbError);
const response = await request(app)
.get('/items/recommendations')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
});
describe('GET /:id/reviews', () => {
const mockReviews = [
{
id: 1,
itemId: 1,
itemRating: 5,
itemReview: 'Great item!',
itemReviewVisible: true,
status: 'completed',
createdAt: new Date('2023-01-01'),
renter: { id: 1, firstName: 'John', lastName: 'Doe' }
},
{
id: 2,
itemId: 1,
itemRating: 4,
itemReview: 'Good quality',
itemReviewVisible: true,
status: 'completed',
createdAt: new Date('2023-01-02'),
renter: { id: 2, firstName: 'Jane', lastName: 'Smith' }
}
];
it('should get reviews for a specific item', async () => {
mockRentalFindAll.mockResolvedValue(mockReviews);
const response = await request(app)
.get('/items/1/reviews');
expect(response.status).toBe(200);
expect(response.body).toEqual({
reviews: [
{
id: 1,
itemId: 1,
itemRating: 5,
itemReview: 'Great item!',
itemReviewVisible: true,
status: 'completed',
createdAt: '2023-01-01T00:00:00.000Z',
renter: { id: 1, firstName: 'John', lastName: 'Doe' }
},
{
id: 2,
itemId: 1,
itemRating: 4,
itemReview: 'Good quality',
itemReviewVisible: true,
status: 'completed',
createdAt: '2023-01-02T00:00:00.000Z',
renter: { id: 2, firstName: 'Jane', lastName: 'Smith' }
}
],
averageRating: 4.5,
totalReviews: 2
});
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: {
itemId: '1',
status: 'completed',
itemRating: { not: null },
itemReview: { not: null },
itemReviewVisible: true
},
include: [
{
model: mockUserModel,
as: 'renter',
attributes: ['id', 'firstName', 'lastName']
}
],
order: [['createdAt', 'DESC']]
});
});
it('should handle no reviews found', async () => {
mockRentalFindAll.mockResolvedValue([]);
const response = await request(app)
.get('/items/1/reviews');
expect(response.status).toBe(200);
expect(response.body).toEqual({
reviews: [],
averageRating: 0,
totalReviews: 0
});
});
it('should handle database errors', async () => {
const dbError = new Error('Database error');
mockRentalFindAll.mockRejectedValue(dbError);
const response = await request(app)
.get('/items/1/reviews');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
});
describe('GET /:id', () => {
const mockItem = {
id: 1,
name: 'Test Item',
latitude: 40.7484405,
longitude: -73.9856644,
toJSON: () => ({
id: 1,
name: 'Test Item',
latitude: 40.7484405,
longitude: -73.9856644,
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' }
})
};
it('should get a specific item by ID', async () => {
mockItemFindByPk.mockResolvedValue(mockItem);
const response = await request(app)
.get('/items/1');
expect(response.status).toBe(200);
expect(response.body).toEqual({
id: 1,
name: 'Test Item',
latitude: 40.75, // Rounded
longitude: -73.99, // Rounded
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' }
});
expect(mockItemFindByPk).toHaveBeenCalledWith('1', {
include: [
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
}
]
});
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.get('/items/999');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Item not found' });
});
it('should handle database errors', async () => {
const dbError = new Error('Database error');
mockItemFindByPk.mockRejectedValue(dbError);
const response = await request(app)
.get('/items/1');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
});
describe('POST /', () => {
const newItemData = {
name: 'New Item',
description: 'A new test item',
pricePerDay: 25.99,
category: 'electronics'
};
const mockCreatedItem = {
id: 1,
...newItemData,
ownerId: 1
};
const mockItemWithOwner = {
id: 1,
...newItemData,
ownerId: 1,
owner: { id: 1, username: 'user1', firstName: 'John', lastName: 'Doe' }
};
it('should create a new item', async () => {
mockItemCreate.mockResolvedValue(mockCreatedItem);
mockItemFindByPk.mockResolvedValue(mockItemWithOwner);
const response = await request(app)
.post('/items')
.set('Authorization', 'Bearer valid_token')
.send(newItemData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockItemWithOwner);
expect(mockItemCreate).toHaveBeenCalledWith({
...newItemData,
ownerId: 1
});
expect(mockItemFindByPk).toHaveBeenCalledWith(1, {
include: [
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
}
]
});
});
it('should require authentication', async () => {
const response = await request(app)
.post('/items')
.send(newItemData);
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'No token provided' });
});
it('should handle database errors during creation', async () => {
const dbError = new Error('Database error');
mockItemCreate.mockRejectedValue(dbError);
const response = await request(app)
.post('/items')
.set('Authorization', 'Bearer valid_token')
.send(newItemData);
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
it('should handle errors during owner fetch', async () => {
mockItemCreate.mockResolvedValue(mockCreatedItem);
const dbError = new Error('Database error');
mockItemFindByPk.mockRejectedValue(dbError);
const response = await request(app)
.post('/items')
.set('Authorization', 'Bearer valid_token')
.send(newItemData);
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
});
describe('PUT /:id', () => {
const updateData = {
name: 'Updated Item',
description: 'Updated description',
pricePerDay: 35.99
};
const mockItem = {
id: 1,
name: 'Original Item',
ownerId: 1,
update: jest.fn()
};
const mockUpdatedItem = {
id: 1,
...updateData,
ownerId: 1,
owner: { id: 1, username: 'user1', firstName: 'John', lastName: 'Doe' }
};
beforeEach(() => {
mockItem.update.mockReset();
});
it('should update an item when user is owner', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockItem) // First call for authorization check
.mockResolvedValueOnce(mockUpdatedItem); // Second call for returning updated item
mockItem.update.mockResolvedValue();
const response = await request(app)
.put('/items/1')
.set('Authorization', 'Bearer valid_token')
.send(updateData);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedItem);
expect(mockItem.update).toHaveBeenCalledWith(updateData);
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.put('/items/999')
.set('Authorization', 'Bearer valid_token')
.send(updateData);
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Item not found' });
});
it('should return 403 when user is not owner', async () => {
const itemOwnedByOther = { ...mockItem, ownerId: 2 };
mockItemFindByPk.mockResolvedValue(itemOwnedByOther);
const response = await request(app)
.put('/items/1')
.set('Authorization', 'Bearer valid_token')
.send(updateData);
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Unauthorized' });
});
it('should require authentication', async () => {
const response = await request(app)
.put('/items/1')
.send(updateData);
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'No token provided' });
});
it('should handle database errors', async () => {
const dbError = new Error('Database error');
mockItemFindByPk.mockRejectedValue(dbError);
const response = await request(app)
.put('/items/1')
.set('Authorization', 'Bearer valid_token')
.send(updateData);
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
it('should handle update errors', async () => {
const updateError = new Error('Update failed');
mockItemFindByPk.mockResolvedValue(mockItem);
mockItem.update.mockRejectedValue(updateError);
const response = await request(app)
.put('/items/1')
.set('Authorization', 'Bearer valid_token')
.send(updateData);
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Update failed' });
});
});
describe('DELETE /:id', () => {
const mockItem = {
id: 1,
name: 'Item to delete',
ownerId: 1,
destroy: jest.fn()
};
beforeEach(() => {
mockItem.destroy.mockReset();
});
it('should delete an item when user is owner', async () => {
mockItemFindByPk.mockResolvedValue(mockItem);
mockItem.destroy.mockResolvedValue();
const response = await request(app)
.delete('/items/1')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(204);
expect(response.body).toEqual({});
expect(mockItem.destroy).toHaveBeenCalled();
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/items/999')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Item not found' });
});
it('should return 403 when user is not owner', async () => {
const itemOwnedByOther = { ...mockItem, ownerId: 2 };
mockItemFindByPk.mockResolvedValue(itemOwnedByOther);
const response = await request(app)
.delete('/items/1')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Unauthorized' });
});
it('should require authentication', async () => {
const response = await request(app)
.delete('/items/1');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'No token provided' });
});
it('should handle database errors', async () => {
const dbError = new Error('Database error');
mockItemFindByPk.mockRejectedValue(dbError);
const response = await request(app)
.delete('/items/1')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
it('should handle deletion errors', async () => {
const deleteError = new Error('Delete failed');
mockItemFindByPk.mockResolvedValue(mockItem);
mockItem.destroy.mockRejectedValue(deleteError);
const response = await request(app)
.delete('/items/1')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Delete failed' });
});
});
describe('Coordinate Rounding', () => {
it('should round coordinates to 2 decimal places', async () => {
const itemWithPreciseCoords = {
toJSON: () => ({
id: 1,
latitude: 40.748440123456,
longitude: -73.985664789012
})
};
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [itemWithPreciseCoords]
});
const response = await request(app)
.get('/items');
expect(response.status).toBe(200);
expect(response.body.items[0].latitude).toBe(40.75);
expect(response.body.items[0].longitude).toBe(-73.99);
});
it('should handle undefined coordinates', async () => {
const itemWithUndefinedCoords = {
toJSON: () => ({
id: 1,
latitude: undefined,
longitude: undefined
})
};
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [itemWithUndefinedCoords]
});
const response = await request(app)
.get('/items');
expect(response.status).toBe(200);
expect(response.body.items[0].latitude).toBeUndefined();
expect(response.body.items[0].longitude).toBeUndefined();
});
it('should handle zero coordinates', async () => {
const itemWithZeroCoords = {
toJSON: () => ({
id: 1,
latitude: 0,
longitude: 0
})
};
mockItemFindAndCountAll.mockResolvedValue({
count: 1,
rows: [itemWithZeroCoords]
});
const response = await request(app)
.get('/items');
expect(response.status).toBe(200);
expect(response.body.items[0].latitude).toBe(0);
expect(response.body.items[0].longitude).toBe(0);
});
});
describe('Edge Cases', () => {
it('should handle very large page numbers', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 0,
rows: []
});
const response = await request(app)
.get('/items?page=999999&limit=10');
expect(response.status).toBe(200);
expect(response.body.currentPage).toBe(999999);
expect(response.body.totalPages).toBe(0);
});
it('should handle invalid query parameters gracefully', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 0,
rows: []
});
const response = await request(app)
.get('/items?page=invalid&limit=abc&minPrice=notanumber');
expect(response.status).toBe(200);
// Express will handle invalid numbers, typically converting to NaN or 0
});
it('should handle empty search queries', async () => {
mockItemFindAndCountAll.mockResolvedValue({
count: 0,
rows: []
});
const response = await request(app)
.get('/items?search=');
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {},
include: expect.any(Array),
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']]
});
});
});
});