Files
rentall-app/backend/tests/unit/routes/rentals.test.js
2026-01-19 19:22:01 -05:00

1982 lines
61 KiB
JavaScript

const request = require('supertest');
const express = require('express');
const rentalsRouter = require('../../../routes/rentals');
// Mock all dependencies
jest.mock('../../../models', () => ({
Rental: {
findAll: jest.fn(),
findByPk: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
},
Item: {
findByPk: jest.fn(),
},
User: {
findByPk: jest.fn(),
},
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: jest.fn((req, res, next) => {
req.user = { id: 1 };
next();
}),
requireVerifiedEmail: jest.fn((req, res, next) => next()),
}));
jest.mock('../../../utils/rentalDurationCalculator', () => ({
calculateRentalCost: jest.fn(() => 100),
}));
jest.mock('../../../services/email', () => ({
rentalFlow: {
sendRentalRequestEmail: jest.fn(),
sendRentalRequestConfirmationEmail: jest.fn(),
sendRentalApprovalConfirmationEmail: jest.fn(),
sendRentalConfirmation: jest.fn(),
sendRentalDeclinedEmail: jest.fn(),
sendRentalCompletedEmail: jest.fn(),
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', () => ({
withRequestId: jest.fn(() => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
})),
}));
jest.mock('../../../services/lateReturnService', () => ({
calculateLateFee: jest.fn(),
processLateReturn: jest.fn(),
}));
jest.mock('../../../services/damageAssessmentService', () => ({
assessDamage: jest.fn(),
processDamageFee: jest.fn(),
processDamageAssessment: jest.fn(),
}));
jest.mock('../../../utils/feeCalculator', () => ({
calculateRentalFees: jest.fn(() => ({
totalChargedAmount: 120,
platformFee: 20,
payoutAmount: 100,
})),
formatFeesForDisplay: jest.fn(() => ({
baseAmount: '$100.00',
platformFee: '$20.00',
totalAmount: '$120.00',
})),
}));
jest.mock('../../../services/refundService', () => ({
getRefundPreview: jest.fn(),
processCancellation: jest.fn(),
}));
jest.mock('../../../services/stripeService', () => ({
chargePaymentMethod: jest.fn(),
}));
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();
app.use(express.json());
app.use('/rentals', rentalsRouter);
// Error handler middleware
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
// Mock models
const mockRentalFindAll = Rental.findAll;
const mockRentalFindByPk = Rental.findByPk;
const mockRentalFindOne = Rental.findOne;
const mockRentalCreate = Rental.create;
describe('Rentals Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /renting', () => {
it('should get rentals for authenticated user', async () => {
const mockRentals = [
{
id: 1,
renterId: 1,
item: { id: 1, name: 'Test Item' },
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
},
{
id: 2,
renterId: 1,
item: { id: 2, name: 'Another Item' },
owner: { id: 3, username: 'owner2', firstName: 'Jane', lastName: 'Smith' },
},
];
mockRentalFindAll.mockResolvedValue(mockRentals);
const response = await request(app)
.get('/rentals/renting');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRentals);
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: { renterId: 1 },
include: [
{ model: Item, as: 'item' },
{
model: User,
as: 'owner',
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
},
],
order: [['createdAt', 'DESC']],
});
});
it('should handle database errors', async () => {
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/rentals/renting');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to fetch rentals' });
});
});
describe('GET /owning', () => {
it('should get listings for authenticated user', async () => {
const mockListings = [
{
id: 1,
ownerId: 1,
item: { id: 1, name: 'My Item' },
renter: { id: 2, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
},
];
mockRentalFindAll.mockResolvedValue(mockListings);
const response = await request(app)
.get('/rentals/owning');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockListings);
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: { ownerId: 1 },
include: [
{ model: Item, as: 'item' },
{
model: User,
as: 'renter',
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
},
],
order: [['createdAt', 'DESC']],
});
});
it('should handle database errors', async () => {
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/rentals/owning');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to fetch listings' });
});
});
describe('GET /:id', () => {
const mockRental = {
id: 1,
ownerId: 2,
renterId: 1,
item: { id: 1, name: 'Test Item' },
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
};
it('should get rental by ID for authorized user (renter)', async () => {
mockRentalFindByPk.mockResolvedValue(mockRental);
const response = await request(app)
.get('/rentals/1');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRental);
});
it('should get rental by ID for authorized user (owner)', async () => {
const ownerRental = { ...mockRental, ownerId: 1, renterId: 2 };
mockRentalFindByPk.mockResolvedValue(ownerRental);
const response = await request(app)
.get('/rentals/1');
expect(response.status).toBe(200);
expect(response.body).toEqual(ownerRental);
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.get('/rentals/999');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for unauthorized user', async () => {
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
const response = await request(app)
.get('/rentals/1');
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Unauthorized to view this rental' });
});
it('should handle database errors', async () => {
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/rentals/1');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to fetch rental' });
});
});
describe('POST /', () => {
// Helper to generate future dates for testing
const getFutureDate = (daysFromNow, hours = 10) => {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
date.setHours(hours, 0, 0, 0);
return date.toISOString();
};
const mockItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isAvailable: true,
pricePerHour: 10,
pricePerDay: 50,
};
const mockCreatedRental = {
id: 1,
itemId: 1,
renterId: 1,
ownerId: 2,
totalAmount: 120,
platformFee: 20,
payoutAmount: 100,
status: 'pending',
};
const mockRentalWithDetails = {
...mockCreatedRental,
item: mockItem,
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
};
// Use dynamic future dates to avoid "Start date cannot be in the past" errors
const rentalData = {
itemId: 1,
startDateTime: getFutureDate(7, 10), // 7 days from now at 10:00
endDateTime: getFutureDate(7, 18), // 7 days from now at 18:00
deliveryMethod: 'pickup',
deliveryAddress: null,
stripePaymentMethodId: 'pm_test123',
};
beforeEach(() => {
Item.findByPk.mockResolvedValue(mockItem);
mockRentalFindOne.mockResolvedValue(null); // No overlapping rentals
mockRentalCreate.mockResolvedValue(mockCreatedRental);
mockRentalFindByPk.mockResolvedValue(mockRentalWithDetails);
});
it('should create a new rental with hourly pricing', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(80); // 8 hours * 10/hour
const response = await request(app)
.post('/rentals')
.send(rentalData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockRentalWithDetails);
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(80); // 8 hours * 10/hour
});
it('should create a new rental with daily pricing', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(150); // 3 days * 50/day
const dailyRentalData = {
...rentalData,
endDateTime: getFutureDate(10, 18), // 3 days from start (7 + 3 = 10 days from now)
};
const response = await request(app)
.post('/rentals')
.send(dailyRentalData);
expect(response.status).toBe(201);
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(150); // 3 days * 50/day
});
it('should return 404 for non-existent item', async () => {
Item.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/rentals')
.send(rentalData);
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Item not found' });
});
it('should return 400 for unavailable item', async () => {
Item.findByPk.mockResolvedValue({ ...mockItem, isAvailable: false });
const response = await request(app)
.post('/rentals')
.send(rentalData);
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Item is not available' });
});
it('should return 400 for overlapping rental', async () => {
mockRentalFindOne.mockResolvedValue({ id: 999 }); // Overlapping rental exists
const response = await request(app)
.post('/rentals')
.send(rentalData);
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Item is already booked for these dates' });
});
it('should return 400 when payment method is missing for paid rentals', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(100); // Paid rental
const dataWithoutPayment = { ...rentalData };
delete dataWithoutPayment.stripePaymentMethodId;
const response = await request(app)
.post('/rentals')
.send(dataWithoutPayment);
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Payment method is required for paid rentals' });
});
it('should create a free rental without payment method', async () => {
RentalDurationCalculator.calculateRentalCost.mockReturnValue(0); // Free rental
// Set up a free item (both prices are 0)
Item.findByPk.mockResolvedValue({
id: 1,
ownerId: 2,
isAvailable: true,
pricePerHour: 0,
pricePerDay: 0
});
const freeRentalData = { ...rentalData };
delete freeRentalData.stripePaymentMethodId;
const createdRental = {
id: 1,
...freeRentalData,
renterId: 1,
ownerId: 2,
totalAmount: 0,
platformFee: 0,
payoutAmount: 0,
paymentStatus: 'not_required',
status: 'pending'
};
Rental.create.mockResolvedValue(createdRental);
Rental.findByPk.mockResolvedValue({
...createdRental,
item: { id: 1, name: 'Free Item' },
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
renter: { id: 1, username: 'renter1', firstName: 'Jane', lastName: 'Smith' }
});
const response = await request(app)
.post('/rentals')
.send(freeRentalData);
expect(response.status).toBe(201);
expect(response.body.paymentStatus).toBe('not_required');
expect(response.body.totalAmount).toBe(0);
});
it('should handle database errors during creation', async () => {
mockRentalCreate.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/rentals')
.send(rentalData);
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to create rental' });
});
});
describe('PUT /:id/status', () => {
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',
stripeCustomerId: 'cus_test123'
},
update: jest.fn(),
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should update rental status to confirmed without payment processing', async () => {
const nonPendingRental = { ...mockRental, status: 'active' };
mockRentalFindByPk.mockResolvedValueOnce(nonPendingRental);
const updatedRental = { ...nonPendingRental, status: 'confirmed' };
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(200);
expect(nonPendingRental.update).toHaveBeenCalledWith({ status: 'confirmed' });
});
it('should process payment when owner approves pending rental', async () => {
// Use the original mockRental (status: 'pending') for this test
mockRentalFindByPk.mockResolvedValueOnce(mockRental);
StripeService.chargePaymentMethod.mockResolvedValue({
paymentIntentId: 'pi_test123',
paymentMethod: {
brand: 'visa',
last4: '4242'
},
chargedAt: new Date('2024-01-15T10:00:00.000Z')
});
const updatedRental = {
...mockRental,
status: 'confirmed',
paymentStatus: 'paid',
stripePaymentIntentId: 'pi_test123'
};
mockRentalFindByPk.mockResolvedValueOnce(updatedRental);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(200);
expect(StripeService.chargePaymentMethod).toHaveBeenCalledWith(
'pm_test123',
120,
'cus_test123',
expect.objectContaining({
rentalId: 1,
itemName: 'Test Item',
})
);
expect(mockRental.update).toHaveBeenCalledWith({
status: 'confirmed',
paymentStatus: 'paid',
stripePaymentIntentId: 'pi_test123',
paymentMethodBrand: 'visa',
paymentMethodLast4: '4242',
chargedAt: new Date('2024-01-15T10:00:00.000Z'),
});
});
it('should approve free rental without payment processing', async () => {
const freeRental = {
...mockRental,
totalAmount: 0,
paymentStatus: 'not_required',
stripePaymentMethodId: null,
update: jest.fn().mockResolvedValue(true)
};
mockRentalFindByPk.mockResolvedValueOnce(freeRental);
const updatedFreeRental = {
...freeRental,
status: 'confirmed'
};
mockRentalFindByPk.mockResolvedValueOnce(updatedFreeRental);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(200);
expect(StripeService.chargePaymentMethod).not.toHaveBeenCalled();
expect(freeRental.update).toHaveBeenCalledWith({
status: 'confirmed'
});
});
it('should return 400 when renter has no Stripe customer ID', async () => {
const rentalWithoutStripeCustomer = {
...mockRental,
renter: { ...mockRental.renter, stripeCustomerId: null }
};
mockRentalFindByPk.mockResolvedValue(rentalWithoutStripeCustomer);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'Renter does not have a Stripe customer account'
});
});
it('should handle payment failure during approval', async () => {
StripeService.chargePaymentMethod.mockRejectedValue(
new Error('Payment failed')
);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(402);
expect(response.body).toEqual({
error: 'payment_failed',
code: 'unknown_error',
ownerMessage: 'The payment could not be processed.',
renterMessage: 'Your payment could not be processed. Please try a different payment method.',
rentalId: 1,
});
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for unauthorized user', async () => {
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Unauthorized to update this rental' });
});
it('should handle database errors', async () => {
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.put('/rentals/1/status')
.send({ status: 'confirmed' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to update rental status' });
});
});
describe('POST /:id/review-renter', () => {
const mockRental = {
id: 1,
ownerId: 1,
renterId: 2,
status: 'completed',
renterReviewSubmittedAt: null,
update: jest.fn(),
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should allow owner to review renter', async () => {
const reviewData = {
rating: 5,
review: 'Great renter!',
privateMessage: 'Thanks for taking care of my item',
};
mockRental.update.mockResolvedValue();
const response = await request(app)
.post('/rentals/1/review-renter')
.send(reviewData);
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
});
expect(mockRental.update).toHaveBeenCalledWith({
renterRating: 5,
renterReview: 'Great renter!',
renterReviewSubmittedAt: expect.any(Date),
renterPrivateMessage: 'Thanks for taking care of my item',
});
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.post('/rentals/1/review-renter')
.send({ rating: 5, review: 'Great!' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for non-owner', async () => {
const nonOwnerRental = { ...mockRental, ownerId: 3 };
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
const response = await request(app)
.post('/rentals/1/review-renter')
.send({ rating: 5, review: 'Great!' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Only owners can review renters' });
});
it('should return 400 for non-completed rental', async () => {
const activeRental = { ...mockRental, status: 'active' };
mockRentalFindByPk.mockResolvedValue(activeRental);
const response = await request(app)
.post('/rentals/1/review-renter')
.send({ rating: 5, review: 'Great!' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Can only review completed rentals' });
});
it('should return 400 if review already submitted', async () => {
const reviewedRental = {
...mockRental,
renterReviewSubmittedAt: new Date()
};
mockRentalFindByPk.mockResolvedValue(reviewedRental);
const response = await request(app)
.post('/rentals/1/review-renter')
.send({ rating: 5, review: 'Great!' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Renter review already submitted' });
});
it('should handle database errors', async () => {
mockRentalFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/rentals/1/review-renter')
.send({ rating: 5, review: 'Great!' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to submit review' });
});
});
describe('POST /:id/review-item', () => {
const mockRental = {
id: 1,
ownerId: 2,
renterId: 1,
status: 'completed',
itemReviewSubmittedAt: null,
update: jest.fn(),
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should allow renter to review item', async () => {
const reviewData = {
rating: 4,
review: 'Good item!',
privateMessage: 'Item was as described',
};
mockRental.update.mockResolvedValue();
const response = await request(app)
.post('/rentals/1/review-item')
.send(reviewData);
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
});
expect(mockRental.update).toHaveBeenCalledWith({
itemRating: 4,
itemReview: 'Good item!',
itemReviewSubmittedAt: expect.any(Date),
itemPrivateMessage: 'Item was as described',
});
});
it('should return 403 for non-renter', async () => {
const nonRenterRental = { ...mockRental, renterId: 3 };
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
const response = await request(app)
.post('/rentals/1/review-item')
.send({ rating: 4, review: 'Good!' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Only renters can review items' });
});
it('should return 400 if review already submitted', async () => {
const reviewedRental = {
...mockRental,
itemReviewSubmittedAt: new Date()
};
mockRentalFindByPk.mockResolvedValue(reviewedRental);
const response = await request(app)
.post('/rentals/1/review-item')
.send({ rating: 4, review: 'Good!' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Item review already submitted' });
});
});
describe('POST /calculate-fees', () => {
it('should calculate fees for given amount', async () => {
const response = await request(app)
.post('/rentals/calculate-fees')
.send({ totalAmount: 100 });
expect(response.status).toBe(200);
expect(response.body).toEqual({
fees: {
totalChargedAmount: 120,
platformFee: 20,
payoutAmount: 100,
},
display: {
baseAmount: '$100.00',
platformFee: '$20.00',
totalAmount: '$120.00',
},
});
expect(FeeCalculator.calculateRentalFees).toHaveBeenCalledWith(100);
expect(FeeCalculator.formatFeesForDisplay).toHaveBeenCalled();
});
it('should return 400 for invalid amount', async () => {
const response = await request(app)
.post('/rentals/calculate-fees')
.send({ totalAmount: 0 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Valid base amount is required' });
});
it('should handle calculation errors', async () => {
FeeCalculator.calculateRentalFees.mockImplementation(() => {
throw new Error('Calculation error');
});
const response = await request(app)
.post('/rentals/calculate-fees')
.send({ totalAmount: 100 });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to calculate fees' });
});
});
describe('GET /earnings/status', () => {
it('should get earnings status for owner', async () => {
const mockEarnings = [
{
id: 1,
totalAmount: 120,
platformFee: 20,
payoutAmount: 100,
payoutStatus: 'completed',
payoutProcessedAt: '2024-01-15T10:00:00.000Z',
stripeTransferId: 'tr_test123',
item: { name: 'Test Item' },
},
];
mockRentalFindAll.mockResolvedValue(mockEarnings);
const response = await request(app)
.get('/rentals/earnings/status');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockEarnings);
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: {
ownerId: 1,
status: 'completed',
},
attributes: [
'id',
'totalAmount',
'platformFee',
'payoutAmount',
'payoutStatus',
'payoutProcessedAt',
'stripeTransferId',
'bankDepositStatus',
'bankDepositAt',
'bankDepositFailureCode',
],
include: [{ model: Item, as: 'item', attributes: ['name'] }],
order: [['createdAt', 'DESC']],
});
});
it('should handle database errors', async () => {
mockRentalFindAll.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/rentals/earnings/status');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
});
describe('GET /:id/refund-preview', () => {
it('should get refund preview', async () => {
const mockPreview = {
refundAmount: 80,
refundPercentage: 80,
reason: 'Cancelled more than 24 hours before start',
};
RefundService.getRefundPreview.mockResolvedValue(mockPreview);
const response = await request(app)
.get('/rentals/1/refund-preview');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockPreview);
expect(RefundService.getRefundPreview).toHaveBeenCalledWith('1', 1);
});
it('should handle refund service errors', async () => {
RefundService.getRefundPreview.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.get('/rentals/1/refund-preview');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Rental not found' });
});
});
describe('POST /:id/cancel', () => {
it('should cancel rental with refund', async () => {
const mockResult = {
rental: {
id: 1,
status: 'cancelled',
},
refund: {
amount: 80,
stripeRefundId: 'rf_test123',
},
};
const mockUpdatedRental = {
id: 1,
status: 'cancelled',
item: { id: 1, name: 'Test Item' },
owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' },
renter: { id: 1, username: 'renter1', firstName: 'Alice', lastName: 'Johnson' },
};
RefundService.processCancellation.mockResolvedValue(mockResult);
mockRentalFindByPk.mockResolvedValue(mockUpdatedRental);
const response = await request(app)
.post('/rentals/1/cancel')
.send({ reason: 'Change of plans' });
expect(response.status).toBe(200);
expect(response.body).toEqual({
rental: mockUpdatedRental,
refund: mockResult.refund,
});
expect(RefundService.processCancellation).toHaveBeenCalledWith(
'1',
1,
'Change of plans'
);
});
it('should handle cancellation errors', async () => {
RefundService.processCancellation.mockRejectedValue(
new Error('Cannot cancel completed rental')
);
const response = await request(app)
.post('/rentals/1/cancel')
.send({ reason: 'Change of plans' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Cannot cancel completed rental' });
});
it('should return 400 when reason is not provided', async () => {
const response = await request(app)
.post('/rentals/1/cancel')
.send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Cancellation reason is required' });
});
});
describe('GET /pending-requests-count', () => {
it('should return count of pending requests for owner', async () => {
Rental.count = jest.fn().mockResolvedValue(5);
const response = await request(app)
.get('/rentals/pending-requests-count');
expect(response.status).toBe(200);
expect(response.body).toEqual({ count: 5 });
expect(Rental.count).toHaveBeenCalledWith({
where: {
ownerId: 1,
status: 'pending',
},
});
});
it('should handle database errors', async () => {
Rental.count = jest.fn().mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/rentals/pending-requests-count');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to get pending rental count' });
});
});
describe('PUT /:id/decline', () => {
const mockRental = {
id: 1,
ownerId: 1,
renterId: 2,
status: 'pending',
item: { id: 1, name: 'Test Item' },
owner: { id: 1, firstName: 'John', lastName: 'Doe' },
renter: { id: 2, firstName: 'Alice', lastName: 'Johnson', email: 'alice@example.com' },
update: jest.fn(),
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should decline rental request with reason', async () => {
mockRental.update.mockResolvedValue();
mockRentalFindByPk
.mockResolvedValueOnce(mockRental)
.mockResolvedValueOnce({ ...mockRental, status: 'declined', declineReason: 'Item not available' });
const response = await request(app)
.put('/rentals/1/decline')
.send({ reason: 'Item not available' });
expect(response.status).toBe(200);
expect(mockRental.update).toHaveBeenCalledWith({
status: 'declined',
declineReason: 'Item not available',
});
});
it('should return 400 when reason is not provided', async () => {
const response = await request(app)
.put('/rentals/1/decline')
.send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'A reason for declining is required' });
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.put('/rentals/1/decline')
.send({ reason: 'Not available' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for non-owner', async () => {
const nonOwnerRental = { ...mockRental, ownerId: 3 };
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
const response = await request(app)
.put('/rentals/1/decline')
.send({ reason: 'Not available' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Only the item owner can decline rental requests' });
});
it('should return 400 for non-pending rental', async () => {
const confirmedRental = { ...mockRental, status: 'confirmed' };
mockRentalFindByPk.mockResolvedValue(confirmedRental);
const response = await request(app)
.put('/rentals/1/decline')
.send({ reason: 'Not available' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Can only decline pending rental requests' });
});
});
describe('POST /cost-preview', () => {
it('should return 400 for missing required fields', async () => {
const response = await request(app)
.post('/rentals/cost-preview')
.send({ itemId: 1 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'itemId, startDateTime, and endDateTime are required' });
});
});
describe('GET /:id/late-fee-preview', () => {
const LateReturnService = require('../../../services/lateReturnService');
const mockRental = {
id: 1,
ownerId: 1,
renterId: 2,
endDateTime: new Date('2024-01-15T18:00:00.000Z'),
item: { id: 1, name: 'Test Item' },
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should return late fee preview', async () => {
LateReturnService.calculateLateFee.mockReturnValue({
isLate: true,
hoursLate: 5,
lateFee: 50,
});
const response = await request(app)
.get('/rentals/1/late-fee-preview')
.query({ actualReturnDateTime: '2024-01-15T23:00:00.000Z' });
expect(response.status).toBe(200);
expect(response.body.isLate).toBe(true);
expect(response.body.lateFee).toBe(50);
});
it('should return 400 when actualReturnDateTime is missing', async () => {
const response = await request(app)
.get('/rentals/1/late-fee-preview');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'actualReturnDateTime is required' });
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.get('/rentals/1/late-fee-preview')
.query({ actualReturnDateTime: '2024-01-15T23:00:00.000Z' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for unauthorized user', async () => {
const unauthorizedRental = { ...mockRental, ownerId: 3, renterId: 4 };
mockRentalFindByPk.mockResolvedValue(unauthorizedRental);
const response = await request(app)
.get('/rentals/1/late-fee-preview')
.query({ actualReturnDateTime: '2024-01-15T23:00:00.000Z' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Unauthorized' });
});
});
describe('POST /:id/mark-return', () => {
const 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'),
item: { id: 1, name: 'Test Item' },
update: jest.fn().mockResolvedValue(),
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'returned' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for non-owner', async () => {
const nonOwnerRental = { ...mockRental, ownerId: 3 };
mockRentalFindByPk.mockResolvedValue(nonOwnerRental);
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'returned' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Only the item owner can mark return status' });
});
it('should return 400 for invalid status', async () => {
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'invalid_status' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('Invalid status');
});
it('should return 400 for non-active rental', async () => {
const completedRental = { ...mockRental, status: 'completed' };
mockRentalFindByPk.mockResolvedValue(completedRental);
const response = await request(app)
.post('/rentals/1/mark-return')
.send({ status: 'returned' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('active rentals');
});
});
describe('PUT /:id/payment-method', () => {
const mockRental = {
id: 1,
ownerId: 2,
renterId: 1,
status: 'pending',
paymentStatus: 'pending',
stripePaymentMethodId: 'pm_old123',
item: { id: 1, name: 'Test Item' },
owner: { id: 2, firstName: 'John', email: 'john@example.com' },
};
beforeEach(() => {
mockRentalFindByPk.mockResolvedValue(mockRental);
StripeService.getPaymentMethod = jest.fn().mockResolvedValue({
id: 'pm_new123',
customer: 'cus_test123',
});
User.findByPk = jest.fn().mockResolvedValue({
id: 1,
stripeCustomerId: 'cus_test123',
});
Rental.update = jest.fn().mockResolvedValue([1]);
});
it('should update payment method successfully', async () => {
const response = await request(app)
.put('/rentals/1/payment-method')
.send({ stripePaymentMethodId: 'pm_new123' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('should return 400 when payment method ID is missing', async () => {
const response = await request(app)
.put('/rentals/1/payment-method')
.send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Payment method ID is required' });
});
it('should return 404 for non-existent rental', async () => {
mockRentalFindByPk.mockResolvedValue(null);
const response = await request(app)
.put('/rentals/1/payment-method')
.send({ stripePaymentMethodId: 'pm_new123' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Rental not found' });
});
it('should return 403 for non-renter', async () => {
const nonRenterRental = { ...mockRental, renterId: 3 };
mockRentalFindByPk.mockResolvedValue(nonRenterRental);
const response = await request(app)
.put('/rentals/1/payment-method')
.send({ stripePaymentMethodId: 'pm_new123' });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Only the renter can update the payment method' });
});
it('should return 400 for non-pending rental', async () => {
const confirmedRental = { ...mockRental, status: 'confirmed' };
mockRentalFindByPk.mockResolvedValue(confirmedRental);
const response = await request(app)
.put('/rentals/1/payment-method')
.send({ stripePaymentMethodId: 'pm_new123' });
expect(response.status).toBe(400);
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');
});
});
});