unit tests

This commit is contained in:
jackiettran
2025-12-12 16:27:56 -05:00
parent 25bbf5d20b
commit 3f319bfdd0
24 changed files with 4282 additions and 1806 deletions

View File

@@ -12,26 +12,19 @@ jest.mock('sequelize', () => ({
}
}));
// 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();
// Mock models - define mocks inline to avoid hoisting issues
jest.mock('../../../models', () => ({
Item: {
findAndCountAll: mockItemFindAndCountAll,
findByPk: mockItemFindByPk,
create: mockItemCreate,
findAll: mockItemFindAll
findAndCountAll: jest.fn(),
findByPk: jest.fn(),
create: jest.fn(),
findAll: jest.fn(),
count: jest.fn()
},
User: mockUserModel,
User: jest.fn(),
Rental: {
findAll: mockRentalFindAll
findAll: jest.fn(),
count: jest.fn()
}
}));
@@ -44,6 +37,30 @@ jest.mock('../../../middleware/auth', () => ({
} 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)
}
}));
@@ -51,17 +68,33 @@ 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(() => {
@@ -161,7 +194,7 @@ describe('Items Routes', () => {
});
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {},
where: { isDeleted: false },
include: [
{
model: mockUserModel,
@@ -190,7 +223,7 @@ describe('Items Routes', () => {
expect(response.body.totalItems).toBe(50);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {},
where: { isDeleted: false },
include: expect.any(Array),
limit: 10,
offset: 20, // (page 3 - 1) * limit 10
@@ -210,6 +243,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
pricePerDay: {
gte: '20',
lte: '30'
@@ -234,6 +268,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
pricePerDay: {
gte: '30'
}
@@ -257,6 +292,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
pricePerDay: {
lte: '30'
}
@@ -280,6 +316,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
city: { iLike: '%New York%' }
},
include: expect.any(Array),
@@ -301,6 +338,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
zipCode: { iLike: '%10001%' }
},
include: expect.any(Array),
@@ -322,6 +360,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
or: [
{ name: { iLike: '%camping%' } },
{ description: { iLike: '%camping%' } }
@@ -346,6 +385,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {
isDeleted: false,
pricePerDay: {
gte: '20',
lte: '30'
@@ -609,6 +649,11 @@ describe('Items Routes', () => {
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
},
{
model: mockUserModel,
as: 'deleter',
attributes: ['id', 'firstName', 'lastName']
}
]
});
@@ -640,8 +685,7 @@ describe('Items Routes', () => {
const newItemData = {
name: 'New Item',
description: 'A new test item',
pricePerDay: 25.99,
category: 'electronics'
pricePerDay: 25.99
};
const mockCreatedItem = {
@@ -679,7 +723,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'email', 'stripeConnectedAccountId']
}
]
});
@@ -1015,7 +1059,7 @@ describe('Items Routes', () => {
expect(response.status).toBe(200);
expect(mockItemFindAndCountAll).toHaveBeenCalledWith({
where: {},
where: { isDeleted: false },
include: expect.any(Array),
limit: 20,
offset: 0,
@@ -1023,4 +1067,292 @@ describe('Items Routes', () => {
});
});
});
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: []
})
);
});
});
});
});