backend unit test coverage to 80%

This commit is contained in:
jackiettran
2026-01-19 19:22:01 -05:00
parent d4362074f5
commit 1923ffc251
8 changed files with 3183 additions and 7 deletions

View File

@@ -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');
});
});
});