backend unit test coverage to 80%
This commit is contained in:
@@ -29,18 +29,27 @@ jest.mock('../../../models', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Track whether to simulate admin user
|
||||
let mockIsAdmin = true;
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
authenticateToken: (req, res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
req.user = { id: 1 };
|
||||
req.user = { id: 1, role: mockIsAdmin ? 'admin' : 'user' };
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
},
|
||||
requireVerifiedEmail: (req, res, next) => next(),
|
||||
requireAdmin: (req, res, next) => next(),
|
||||
requireAdmin: (req, res, next) => {
|
||||
if (req.user && req.user.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
},
|
||||
optionalAuth: (req, res, next) => next()
|
||||
}));
|
||||
|
||||
@@ -76,6 +85,7 @@ const mockItemCreate = Item.create;
|
||||
const mockItemFindAll = Item.findAll;
|
||||
const mockItemCount = Item.count;
|
||||
const mockRentalFindAll = Rental.findAll;
|
||||
const mockRentalCount = Rental.count;
|
||||
const mockUserModel = User;
|
||||
|
||||
// Set up Express app for testing
|
||||
@@ -96,6 +106,7 @@ describe('Items Routes', () => {
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
mockItemCount.mockResolvedValue(1); // Default to not first listing
|
||||
mockIsAdmin = true; // Default to admin user
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1404,4 +1415,303 @@ describe('Items Routes', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /admin/:id (Admin Soft Delete)', () => {
|
||||
const mockItem = {
|
||||
id: 1,
|
||||
name: 'Test Item',
|
||||
ownerId: 2,
|
||||
isDeleted: false,
|
||||
owner: { id: 2, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
const mockUpdatedItem = {
|
||||
id: 1,
|
||||
name: 'Test Item',
|
||||
ownerId: 2,
|
||||
isDeleted: true,
|
||||
deletedBy: 1,
|
||||
deletedAt: expect.any(Date),
|
||||
deletionReason: 'Violates terms of service',
|
||||
owner: { id: 2, firstName: 'John', lastName: 'Doe' },
|
||||
deleter: { id: 1, firstName: 'Admin', lastName: 'User' }
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockItem.update.mockReset();
|
||||
mockRentalCount.mockResolvedValue(0); // No active rentals by default
|
||||
});
|
||||
|
||||
it('should soft delete item as admin with valid reason', async () => {
|
||||
mockItemFindByPk
|
||||
.mockResolvedValueOnce(mockItem)
|
||||
.mockResolvedValueOnce(mockUpdatedItem);
|
||||
mockItem.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockItem.update).toHaveBeenCalledWith({
|
||||
isDeleted: true,
|
||||
deletedBy: 1,
|
||||
deletedAt: expect.any(Date),
|
||||
deletionReason: 'Violates terms of service'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return updated item with deleter information', async () => {
|
||||
mockItemFindByPk
|
||||
.mockResolvedValueOnce(mockItem)
|
||||
.mockResolvedValueOnce(mockUpdatedItem);
|
||||
mockItem.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deleter).toBeDefined();
|
||||
expect(response.body.isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 400 when reason is missing', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Deletion reason is required');
|
||||
});
|
||||
|
||||
it('should return 400 when reason is empty', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: ' ' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Deletion reason is required');
|
||||
});
|
||||
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('No token provided');
|
||||
});
|
||||
|
||||
it('should return 403 when user is not admin', async () => {
|
||||
mockIsAdmin = false;
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Admin access required');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
mockItemFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/999')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Item not found');
|
||||
});
|
||||
|
||||
it('should return 400 when item is already deleted', async () => {
|
||||
const deletedItem = { ...mockItem, isDeleted: true };
|
||||
mockItemFindByPk.mockResolvedValue(deletedItem);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Item is already deleted');
|
||||
});
|
||||
|
||||
it('should return 400 when item has active rentals', async () => {
|
||||
mockItemFindByPk.mockResolvedValue(mockItem);
|
||||
mockRentalCount.mockResolvedValue(2);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Cannot delete item with active or upcoming rentals');
|
||||
expect(response.body.code).toBe('ACTIVE_RENTALS_EXIST');
|
||||
expect(response.body.activeRentalsCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Database error');
|
||||
});
|
||||
|
||||
it('should handle update errors', async () => {
|
||||
mockItemFindByPk.mockResolvedValue(mockItem);
|
||||
mockItem.update.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/items/admin/1')
|
||||
.set('Authorization', 'Bearer valid_token')
|
||||
.send({ reason: 'Violates terms of service' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /admin/:id/restore (Admin Restore)', () => {
|
||||
const mockDeletedItem = {
|
||||
id: 1,
|
||||
name: 'Test Item',
|
||||
ownerId: 2,
|
||||
isDeleted: true,
|
||||
deletedBy: 1,
|
||||
deletedAt: new Date(),
|
||||
deletionReason: 'Violates terms of service',
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
const mockRestoredItem = {
|
||||
id: 1,
|
||||
name: 'Test Item',
|
||||
ownerId: 2,
|
||||
isDeleted: false,
|
||||
deletedBy: null,
|
||||
deletedAt: null,
|
||||
deletionReason: null,
|
||||
owner: { id: 2, firstName: 'John', lastName: 'Doe' }
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeletedItem.update.mockReset();
|
||||
});
|
||||
|
||||
it('should restore soft-deleted item as admin', async () => {
|
||||
mockItemFindByPk
|
||||
.mockResolvedValueOnce(mockDeletedItem)
|
||||
.mockResolvedValueOnce(mockRestoredItem);
|
||||
mockDeletedItem.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDeletedItem.update).toHaveBeenCalledWith({
|
||||
isDeleted: false,
|
||||
deletedBy: null,
|
||||
deletedAt: null,
|
||||
deletionReason: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear deletion fields after restore', async () => {
|
||||
mockItemFindByPk
|
||||
.mockResolvedValueOnce(mockDeletedItem)
|
||||
.mockResolvedValueOnce(mockRestoredItem);
|
||||
mockDeletedItem.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.isDeleted).toBe(false);
|
||||
expect(response.body.deletedBy).toBeNull();
|
||||
expect(response.body.deletedAt).toBeNull();
|
||||
expect(response.body.deletionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('No token provided');
|
||||
});
|
||||
|
||||
it('should return 403 when user is not admin', async () => {
|
||||
mockIsAdmin = false;
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Admin access required');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
mockItemFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/999/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Item not found');
|
||||
});
|
||||
|
||||
it('should return 400 when item is not deleted', async () => {
|
||||
const activeItem = { ...mockDeletedItem, isDeleted: false };
|
||||
mockItemFindByPk.mockResolvedValue(activeItem);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Item is not deleted');
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockItemFindByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Database error');
|
||||
});
|
||||
|
||||
it('should handle update errors', async () => {
|
||||
mockItemFindByPk.mockResolvedValue(mockDeletedItem);
|
||||
mockDeletedItem.update.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/items/admin/1/restore')
|
||||
.set('Authorization', 'Bearer valid_token');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toBe('Update failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,9 @@ jest.mock('../../../models', () => ({
|
||||
Item: {
|
||||
findByPk: jest.fn(),
|
||||
},
|
||||
User: jest.fn(),
|
||||
User: {
|
||||
findByPk: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/auth', () => ({
|
||||
@@ -39,10 +41,20 @@ jest.mock('../../../services/email', () => ({
|
||||
sendRentalCancelledEmail: jest.fn(),
|
||||
sendDamageReportEmail: jest.fn(),
|
||||
sendLateReturnNotificationEmail: jest.fn(),
|
||||
sendRentalCompletionEmails: jest.fn().mockResolvedValue(),
|
||||
sendRentalCancellationEmails: jest.fn().mockResolvedValue(),
|
||||
sendAuthenticationRequiredEmail: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
rentalReminder: {
|
||||
sendUpcomingRentalReminder: jest.fn(),
|
||||
},
|
||||
customerService: {
|
||||
sendLostItemToCustomerService: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
payment: {
|
||||
sendPaymentDeclinedNotification: jest.fn().mockResolvedValue(),
|
||||
sendPaymentMethodUpdatedNotification: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/logger', () => ({
|
||||
@@ -61,6 +73,7 @@ jest.mock('../../../services/lateReturnService', () => ({
|
||||
jest.mock('../../../services/damageAssessmentService', () => ({
|
||||
assessDamage: jest.fn(),
|
||||
processDamageFee: jest.fn(),
|
||||
processDamageAssessment: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/feeCalculator', () => ({
|
||||
@@ -89,11 +102,33 @@ jest.mock('../../../services/stripeWebhookService', () => ({
|
||||
reconcilePayoutStatuses: jest.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/payoutService', () => ({
|
||||
triggerPayoutOnCompletion: jest.fn().mockResolvedValue(),
|
||||
processRentalPayout: jest.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/eventBridgeSchedulerService', () => ({
|
||||
createConditionCheckSchedules: jest.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
// Mock stripe module
|
||||
jest.mock('stripe', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
paymentIntents: {
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const { Rental, Item, User } = require('../../../models');
|
||||
const FeeCalculator = require('../../../utils/feeCalculator');
|
||||
const RentalDurationCalculator = require('../../../utils/rentalDurationCalculator');
|
||||
const RefundService = require('../../../services/refundService');
|
||||
const StripeService = require('../../../services/stripeService');
|
||||
const PayoutService = require('../../../services/payoutService');
|
||||
const DamageAssessmentService = require('../../../services/damageAssessmentService');
|
||||
const EventBridgeSchedulerService = require('../../../services/eventBridgeSchedulerService');
|
||||
const stripe = require('stripe');
|
||||
|
||||
// Create express app with the router
|
||||
const app = express();
|
||||
@@ -1319,4 +1354,629 @@ describe('Rentals Routes', () => {
|
||||
expect(response.body).toEqual({ error: 'Can only update payment method for pending rentals' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/report-damage', () => {
|
||||
const validUuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'confirmed',
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
};
|
||||
|
||||
const mockDamageResult = {
|
||||
rental: { id: 1, status: 'damaged' },
|
||||
damageAssessment: {
|
||||
description: 'Screen cracked',
|
||||
feeCalculation: { amount: 150 },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
DamageAssessmentService.processDamageAssessment.mockResolvedValue(mockDamageResult);
|
||||
});
|
||||
|
||||
it('should report damage with all required fields', async () => {
|
||||
const damageData = {
|
||||
description: 'Screen cracked',
|
||||
canBeFixed: true,
|
||||
repairCost: 150,
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send(damageData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(DamageAssessmentService.processDamageAssessment).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({ description: 'Screen cracked' }),
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('should report damage with optional late return', async () => {
|
||||
const damageResultWithLate = {
|
||||
...mockDamageResult,
|
||||
lateCalculation: { lateFee: 50 },
|
||||
};
|
||||
DamageAssessmentService.processDamageAssessment.mockResolvedValue(damageResultWithLate);
|
||||
|
||||
const damageData = {
|
||||
description: 'Screen cracked',
|
||||
canBeFixed: true,
|
||||
repairCost: 150,
|
||||
actualReturnDateTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send(damageData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept damage report without images', async () => {
|
||||
const damageData = {
|
||||
description: 'Screen cracked',
|
||||
canBeFixed: true,
|
||||
repairCost: 150,
|
||||
needsReplacement: false,
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send(damageData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid imageFilenames format', async () => {
|
||||
const damageData = {
|
||||
description: 'Screen cracked',
|
||||
imageFilenames: ['invalid-key.jpg'],
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send(damageData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 400 for non-image extensions', async () => {
|
||||
const damageData = {
|
||||
description: 'Screen cracked',
|
||||
imageFilenames: [`damage-reports/${validUuid}.exe`],
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send(damageData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 for exceeding max images', async () => {
|
||||
const tooManyImages = Array(11).fill(0).map((_, i) =>
|
||||
`damage-reports/550e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}.jpg`
|
||||
);
|
||||
|
||||
const damageData = {
|
||||
description: 'Screen cracked',
|
||||
imageFilenames: tooManyImages,
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send(damageData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should handle damage assessment service errors', async () => {
|
||||
DamageAssessmentService.processDamageAssessment.mockRejectedValue(
|
||||
new Error('Assessment failed')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/report-damage')
|
||||
.send({ description: 'Screen cracked' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/payment-client-secret', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
renterId: 1,
|
||||
stripePaymentIntentId: 'pi_test123',
|
||||
renter: { id: 1, stripeCustomerId: 'cus_test123' },
|
||||
};
|
||||
|
||||
let mockStripeInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
mockStripeInstance = {
|
||||
paymentIntents: {
|
||||
retrieve: jest.fn().mockResolvedValue({
|
||||
client_secret: 'pi_test123_secret_xxx',
|
||||
status: 'requires_action',
|
||||
}),
|
||||
},
|
||||
};
|
||||
stripe.mockImplementation(() => mockStripeInstance);
|
||||
});
|
||||
|
||||
it('should return client secret for renter', async () => {
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/payment-client-secret');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.clientSecret).toBe('pi_test123_secret_xxx');
|
||||
expect(response.body.status).toBe('requires_action');
|
||||
});
|
||||
|
||||
it('should return payment intent status', async () => {
|
||||
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({
|
||||
client_secret: 'pi_test123_secret_xxx',
|
||||
status: 'succeeded',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/payment-client-secret');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/999/payment-client-secret');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Rental not found');
|
||||
});
|
||||
|
||||
it('should return 403 for non-renter', async () => {
|
||||
const nonRenterRental = { ...mockRental, renterId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/payment-client-secret');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Not authorized');
|
||||
});
|
||||
|
||||
it('should return 400 when no payment intent exists', async () => {
|
||||
const rentalWithoutPaymentIntent = { ...mockRental, stripePaymentIntentId: null };
|
||||
mockRentalFindByPk.mockResolvedValue(rentalWithoutPaymentIntent);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/payment-client-secret');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('No payment intent found');
|
||||
});
|
||||
|
||||
it('should handle Stripe API errors', async () => {
|
||||
mockStripeInstance.paymentIntents.retrieve.mockRejectedValue(
|
||||
new Error('Stripe API error')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/rentals/1/payment-client-secret');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/complete-payment', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 2,
|
||||
renterId: 1,
|
||||
status: 'pending',
|
||||
paymentStatus: 'requires_action',
|
||||
stripePaymentIntentId: 'pi_test123',
|
||||
totalAmount: 120,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
renter: { id: 1, firstName: 'Alice', lastName: 'Johnson', email: 'alice@example.com', stripeCustomerId: 'cus_test123' },
|
||||
owner: { id: 2, firstName: 'John', lastName: 'Doe', email: 'john@example.com', stripeConnectedAccountId: 'acct_test123', stripePayoutsEnabled: true },
|
||||
update: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
|
||||
let mockStripeInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
mockRental.update.mockReset();
|
||||
mockStripeInstance = {
|
||||
paymentIntents: {
|
||||
retrieve: jest.fn().mockResolvedValue({
|
||||
status: 'succeeded',
|
||||
latest_charge: {
|
||||
payment_method_details: {
|
||||
type: 'card',
|
||||
card: { brand: 'visa', last4: '4242' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
stripe.mockImplementation(() => mockStripeInstance);
|
||||
});
|
||||
|
||||
it('should complete payment after 3DS authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.rental.status).toBe('confirmed');
|
||||
expect(response.body.rental.paymentStatus).toBe('paid');
|
||||
});
|
||||
|
||||
it('should update rental to confirmed status', async () => {
|
||||
await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
status: 'confirmed',
|
||||
paymentStatus: 'paid',
|
||||
chargedAt: expect.any(Date),
|
||||
paymentMethodBrand: 'visa',
|
||||
paymentMethodLast4: '4242',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create condition check schedules', async () => {
|
||||
await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(EventBridgeSchedulerService.createConditionCheckSchedules).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger payout if owner has payouts enabled', async () => {
|
||||
await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockRental);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent rental', async () => {
|
||||
mockRentalFindByPk.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/999/complete-payment');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Rental not found');
|
||||
});
|
||||
|
||||
it('should return 403 for non-renter', async () => {
|
||||
const nonRenterRental = { ...mockRental, renterId: 3 };
|
||||
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Not authorized');
|
||||
});
|
||||
|
||||
it('should return 400 when payment status is not requires_action', async () => {
|
||||
const paidRental = { ...mockRental, paymentStatus: 'paid' };
|
||||
mockRentalFindByPk.mockResolvedValue(paidRental);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid state');
|
||||
});
|
||||
|
||||
it('should return 402 when payment intent not succeeded', async () => {
|
||||
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({
|
||||
status: 'requires_action',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(response.status).toBe(402);
|
||||
expect(response.body.error).toBe('payment_incomplete');
|
||||
});
|
||||
|
||||
it('should handle Stripe API errors', async () => {
|
||||
mockStripeInstance.paymentIntents.retrieve.mockRejectedValue(
|
||||
new Error('Stripe API error')
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should handle bank account payment methods', async () => {
|
||||
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({
|
||||
status: 'succeeded',
|
||||
latest_charge: {
|
||||
payment_method_details: {
|
||||
type: 'us_bank_account',
|
||||
us_bank_account: { last4: '6789' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/complete-payment');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRental.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paymentMethodBrand: 'bank_account',
|
||||
paymentMethodLast4: '6789',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/mark-return (Additional Cases)', () => {
|
||||
let mockRental;
|
||||
|
||||
const LateReturnService = require('../../../services/lateReturnService');
|
||||
|
||||
beforeEach(() => {
|
||||
mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'confirmed',
|
||||
startDateTime: new Date('2024-01-10T10:00:00.000Z'),
|
||||
endDateTime: new Date('2024-01-15T18:00:00.000Z'),
|
||||
lateFees: 0,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
update: jest.fn(),
|
||||
};
|
||||
// Make update return the modified rental instance
|
||||
mockRental.update.mockImplementation((updates) => {
|
||||
Object.assign(mockRental, updates);
|
||||
return Promise.resolve(mockRental);
|
||||
});
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
});
|
||||
|
||||
it('should mark item as returned with payout trigger', async () => {
|
||||
mockRentalFindByPk.mockResolvedValueOnce(mockRental);
|
||||
const rentalWithDetails = {
|
||||
...mockRental,
|
||||
owner: { id: 1, firstName: 'John', lastName: 'Doe', email: 'john@example.com', stripeConnectedAccountId: 'acct_123' },
|
||||
renter: { id: 2, firstName: 'Alice', lastName: 'Johnson', email: 'alice@example.com' },
|
||||
};
|
||||
mockRentalFindByPk.mockResolvedValueOnce(rentalWithDetails);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-return')
|
||||
.send({ status: 'returned' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(PayoutService.triggerPayoutOnCompletion).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should mark item as damaged', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-return')
|
||||
.send({ status: 'damaged' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRental.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'damaged' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark item as returned_late with late fees', async () => {
|
||||
LateReturnService.processLateReturn.mockResolvedValue({
|
||||
rental: { ...mockRental, status: 'returned_late', lateFees: 50 },
|
||||
lateCalculation: { lateFee: 50, hoursLate: 5 },
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-return')
|
||||
.send({
|
||||
status: 'returned_late',
|
||||
actualReturnDateTime: '2024-01-15T23:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.lateCalculation).toBeDefined();
|
||||
expect(response.body.lateCalculation.lateFee).toBe(50);
|
||||
});
|
||||
|
||||
it('should require actualReturnDateTime for late returns', async () => {
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-return')
|
||||
.send({ status: 'returned_late' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Actual return date/time is required for late returns');
|
||||
});
|
||||
|
||||
it('should mark item as lost with customer service notification', async () => {
|
||||
User.findByPk = jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
email: 'john@example.com',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-return')
|
||||
.send({ status: 'lost' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRental.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'lost',
|
||||
itemLostReportedAt: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle damaged with late return combination', async () => {
|
||||
LateReturnService.processLateReturn.mockResolvedValue({
|
||||
rental: { ...mockRental, lateFees: 50 },
|
||||
lateCalculation: { lateFee: 50, hoursLate: 5 },
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/rentals/1/mark-return')
|
||||
.send({
|
||||
status: 'damaged',
|
||||
actualReturnDateTime: '2024-01-15T23:00:00.000Z',
|
||||
statusOptions: { returned_late: true },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRental.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'returned_late_and_damaged' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/status (3DS Flow)', () => {
|
||||
const mockRental = {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
renterId: 2,
|
||||
status: 'pending',
|
||||
stripePaymentMethodId: 'pm_test123',
|
||||
totalAmount: 120,
|
||||
item: { id: 1, name: 'Test Item' },
|
||||
renter: {
|
||||
id: 2,
|
||||
username: 'renter1',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Johnson',
|
||||
email: 'alice@example.com',
|
||||
stripeCustomerId: 'cus_test123',
|
||||
},
|
||||
owner: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
stripeConnectedAccountId: 'acct_test123',
|
||||
},
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRentalFindByPk.mockResolvedValue(mockRental);
|
||||
mockRental.update.mockReset();
|
||||
});
|
||||
|
||||
it('should handle payment requiring 3DS authentication', async () => {
|
||||
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||
requiresAction: true,
|
||||
paymentIntentId: 'pi_test_3ds',
|
||||
clientSecret: 'pi_test_3ds_secret_xxx',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(402);
|
||||
expect(response.body.error).toBe('authentication_required');
|
||||
expect(response.body.requiresAction).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 402 with requiresAction flag', async () => {
|
||||
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||
requiresAction: true,
|
||||
paymentIntentId: 'pi_test_3ds',
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(402);
|
||||
expect(response.body.requiresAction).toBe(true);
|
||||
expect(response.body.rentalId).toBe(1);
|
||||
});
|
||||
|
||||
it('should store payment intent ID for later completion', async () => {
|
||||
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||
requiresAction: true,
|
||||
paymentIntentId: 'pi_test_3ds',
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(mockRental.update).toHaveBeenCalledWith({
|
||||
stripePaymentIntentId: 'pi_test_3ds',
|
||||
paymentStatus: 'requires_action',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set paymentStatus to requires_action', async () => {
|
||||
StripeService.chargePaymentMethod.mockResolvedValue({
|
||||
requiresAction: true,
|
||||
paymentIntentId: 'pi_test_3ds',
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(mockRental.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paymentStatus: 'requires_action' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle card declined errors', async () => {
|
||||
const declinedError = new Error('Your card was declined');
|
||||
declinedError.code = 'card_declined';
|
||||
|
||||
StripeService.chargePaymentMethod.mockRejectedValue(declinedError);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(402);
|
||||
expect(response.body.error).toBe('payment_failed');
|
||||
expect(response.body.code).toBe('card_declined');
|
||||
});
|
||||
|
||||
it('should handle insufficient funds errors', async () => {
|
||||
const insufficientError = new Error('Insufficient funds');
|
||||
insufficientError.code = 'insufficient_funds';
|
||||
|
||||
StripeService.chargePaymentMethod.mockRejectedValue(insufficientError);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/rentals/1/status')
|
||||
.send({ status: 'confirmed' });
|
||||
|
||||
expect(response.status).toBe(402);
|
||||
expect(response.body.error).toBe('payment_failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user