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: [] }) ); }); }); }); });