1358 lines
38 KiB
JavaScript
1358 lines
38 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 - define mocks inline to avoid hoisting issues
|
|
jest.mock('../../../models', () => ({
|
|
Item: {
|
|
findAndCountAll: jest.fn(),
|
|
findByPk: jest.fn(),
|
|
create: jest.fn(),
|
|
findAll: jest.fn(),
|
|
count: jest.fn()
|
|
},
|
|
User: jest.fn(),
|
|
Rental: {
|
|
findAll: jest.fn(),
|
|
count: 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' });
|
|
}
|
|
},
|
|
requireVerifiedEmail: (req, res, next) => next(),
|
|
requireAdmin: (req, res, next) => next(),
|
|
optionalAuth: (req, res, next) => next()
|
|
}));
|
|
|
|
// Mock logger
|
|
jest.mock('../../../utils/logger', () => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
withRequestId: jest.fn(() => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn()
|
|
})),
|
|
sanitize: jest.fn(data => data)
|
|
}));
|
|
|
|
// Mock email services
|
|
jest.mock('../../../services/email', () => ({
|
|
userEngagement: {
|
|
sendFirstListingCelebrationEmail: jest.fn().mockResolvedValue(true),
|
|
sendItemDeletionNotificationToOwner: jest.fn().mockResolvedValue(true)
|
|
}
|
|
}));
|
|
|
|
const { Item, User, Rental } = require('../../../models');
|
|
const { Op } = require('sequelize');
|
|
const itemsRoutes = require('../../../routes/items');
|
|
|
|
// Get references to the mock functions after importing
|
|
const mockItemFindAndCountAll = Item.findAndCountAll;
|
|
const mockItemFindByPk = Item.findByPk;
|
|
const mockItemCreate = Item.create;
|
|
const mockItemFindAll = Item.findAll;
|
|
const mockItemCount = Item.count;
|
|
const mockRentalFindAll = Rental.findAll;
|
|
const mockUserModel = User;
|
|
|
|
// Set up Express app for testing
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/items', itemsRoutes);
|
|
|
|
// Error handler middleware
|
|
app.use((err, req, res, next) => {
|
|
res.status(500).json({ error: err.message });
|
|
});
|
|
|
|
describe('Items Routes', () => {
|
|
let consoleSpy;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
jest.spyOn(console, 'error').mockImplementation();
|
|
mockItemCount.mockResolvedValue(1); // Default to not first listing
|
|
});
|
|
|
|
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: { isDeleted: false },
|
|
include: [
|
|
{
|
|
model: mockUserModel,
|
|
as: 'owner',
|
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
|
}
|
|
],
|
|
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: { isDeleted: false },
|
|
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: {
|
|
isDeleted: false,
|
|
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: {
|
|
isDeleted: false,
|
|
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: {
|
|
isDeleted: false,
|
|
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: {
|
|
isDeleted: false,
|
|
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: {
|
|
isDeleted: false,
|
|
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: {
|
|
isDeleted: false,
|
|
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: {
|
|
isDeleted: false,
|
|
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', 'imageFilename']
|
|
}
|
|
],
|
|
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', 'imageFilename']
|
|
},
|
|
{
|
|
model: mockUserModel,
|
|
as: 'deleter',
|
|
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
|
|
};
|
|
|
|
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', 'email', 'stripeConnectedAccountId']
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
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: { isDeleted: false },
|
|
include: expect.any(Array),
|
|
limit: 20,
|
|
offset: 0,
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Image Handling', () => {
|
|
const validUuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
|
const validUuid2 = '660e8400-e29b-41d4-a716-446655440001';
|
|
|
|
describe('POST / with imageFilenames', () => {
|
|
const newItemWithImages = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: [
|
|
`items/${validUuid1}.jpg`,
|
|
`items/${validUuid2}.png`
|
|
]
|
|
};
|
|
|
|
const mockCreatedItem = {
|
|
id: 1,
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: newItemWithImages.imageFilenames,
|
|
ownerId: 1
|
|
};
|
|
|
|
it('should create item with valid imageFilenames', async () => {
|
|
mockItemCreate.mockResolvedValue(mockCreatedItem);
|
|
mockItemFindByPk.mockResolvedValue(mockCreatedItem);
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(newItemWithImages);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(mockItemCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
imageFilenames: newItemWithImages.imageFilenames,
|
|
ownerId: 1
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should create item without imageFilenames', async () => {
|
|
const itemWithoutImages = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99
|
|
};
|
|
|
|
mockItemCreate.mockResolvedValue({ ...mockCreatedItem, imageFilenames: null });
|
|
mockItemFindByPk.mockResolvedValue({ ...mockCreatedItem, imageFilenames: null });
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithoutImages);
|
|
|
|
expect(response.status).toBe(201);
|
|
});
|
|
|
|
it('should reject invalid S3 key format', async () => {
|
|
const itemWithInvalidKey = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: ['invalid-key.jpg']
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithInvalidKey);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBeDefined();
|
|
});
|
|
|
|
it('should reject keys with wrong folder prefix', async () => {
|
|
const itemWithWrongFolder = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: [`profiles/${validUuid1}.jpg`]
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithWrongFolder);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should reject exceeding max images (10)', async () => {
|
|
const tooManyImages = Array(11).fill(0).map((_, i) =>
|
|
`items/550e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}.jpg`
|
|
);
|
|
|
|
const itemWithTooManyImages = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: tooManyImages
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithTooManyImages);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('Maximum');
|
|
});
|
|
|
|
it('should accept exactly 10 images', async () => {
|
|
const maxImages = Array(10).fill(0).map((_, i) =>
|
|
`items/550e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}.jpg`
|
|
);
|
|
|
|
const itemWithMaxImages = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: maxImages
|
|
};
|
|
|
|
mockItemCreate.mockResolvedValue({ ...mockCreatedItem, imageFilenames: maxImages });
|
|
mockItemFindByPk.mockResolvedValue({ ...mockCreatedItem, imageFilenames: maxImages });
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithMaxImages);
|
|
|
|
expect(response.status).toBe(201);
|
|
});
|
|
|
|
it('should reject duplicate image keys', async () => {
|
|
const itemWithDuplicates = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: [
|
|
`items/${validUuid1}.jpg`,
|
|
`items/${validUuid1}.jpg`
|
|
]
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithDuplicates);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('Duplicate');
|
|
});
|
|
|
|
it('should reject path traversal attempts', async () => {
|
|
const itemWithPathTraversal = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: [`../items/${validUuid1}.jpg`]
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithPathTraversal);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should reject non-image extensions', async () => {
|
|
const itemWithNonImage = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: [`items/${validUuid1}.exe`]
|
|
};
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithNonImage);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should handle empty imageFilenames array', async () => {
|
|
const itemWithEmptyImages = {
|
|
name: 'New Item',
|
|
description: 'A new test item',
|
|
pricePerDay: 25.99,
|
|
imageFilenames: []
|
|
};
|
|
|
|
mockItemCreate.mockResolvedValue({ ...mockCreatedItem, imageFilenames: [] });
|
|
mockItemFindByPk.mockResolvedValue({ ...mockCreatedItem, imageFilenames: [] });
|
|
|
|
const response = await request(app)
|
|
.post('/items')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send(itemWithEmptyImages);
|
|
|
|
expect(response.status).toBe(201);
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id with imageFilenames', () => {
|
|
const mockItem = {
|
|
id: 1,
|
|
name: 'Original Item',
|
|
ownerId: 1,
|
|
imageFilenames: [`items/${validUuid1}.jpg`],
|
|
update: jest.fn()
|
|
};
|
|
|
|
const mockUpdatedItem = {
|
|
id: 1,
|
|
name: 'Updated Item',
|
|
ownerId: 1,
|
|
imageFilenames: [`items/${validUuid2}.png`],
|
|
owner: { id: 1, firstName: 'John', lastName: 'Doe' }
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockItem.update.mockReset();
|
|
});
|
|
|
|
it('should update item with new imageFilenames', async () => {
|
|
mockItemFindByPk
|
|
.mockResolvedValueOnce(mockItem)
|
|
.mockResolvedValueOnce(mockUpdatedItem);
|
|
mockItem.update.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.put('/items/1')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send({
|
|
name: 'Updated Item',
|
|
imageFilenames: [`items/${validUuid2}.png`]
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockItem.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
imageFilenames: [`items/${validUuid2}.png`]
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should reject invalid imageFilenames on update', async () => {
|
|
mockItemFindByPk.mockResolvedValue(mockItem);
|
|
|
|
const response = await request(app)
|
|
.put('/items/1')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send({
|
|
imageFilenames: ['invalid-key.jpg']
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should allow clearing imageFilenames with empty array', async () => {
|
|
mockItemFindByPk
|
|
.mockResolvedValueOnce(mockItem)
|
|
.mockResolvedValueOnce({ ...mockUpdatedItem, imageFilenames: [] });
|
|
mockItem.update.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.put('/items/1')
|
|
.set('Authorization', 'Bearer valid_token')
|
|
.send({
|
|
imageFilenames: []
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockItem.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
imageFilenames: []
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}); |