1015 lines
30 KiB
JavaScript
1015 lines
30 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: 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/emailService', () => ({
|
|
sendRentalRequestEmail: jest.fn(),
|
|
sendRentalApprovalEmail: jest.fn(),
|
|
sendRentalDeclinedEmail: jest.fn(),
|
|
sendRentalCompletedEmail: jest.fn(),
|
|
sendRentalCancelledEmail: jest.fn(),
|
|
sendDamageReportEmail: jest.fn(),
|
|
sendLateReturnNotificationEmail: jest.fn(),
|
|
}));
|
|
|
|
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(),
|
|
}));
|
|
|
|
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(),
|
|
}));
|
|
|
|
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');
|
|
|
|
// Create express app with the router
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/rentals', rentalsRouter);
|
|
|
|
// 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', 'username', 'firstName', 'lastName'],
|
|
},
|
|
],
|
|
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', 'username', 'firstName', 'lastName'],
|
|
},
|
|
],
|
|
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 /', () => {
|
|
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' },
|
|
};
|
|
|
|
const rentalData = {
|
|
itemId: 1,
|
|
startDateTime: '2024-01-15T10:00:00.000Z',
|
|
endDateTime: '2024-01-15T18:00:00.000Z',
|
|
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: '2024-01-17T18:00:00.000Z', // 3 days
|
|
};
|
|
|
|
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(400);
|
|
expect(response.body).toEqual({
|
|
error: 'Payment failed during approval',
|
|
details: 'Payment failed',
|
|
});
|
|
});
|
|
|
|
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 /:id/mark-completed', () => {
|
|
const mockRental = {
|
|
id: 1,
|
|
ownerId: 1,
|
|
renterId: 2,
|
|
status: 'active',
|
|
update: jest.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockRentalFindByPk.mockResolvedValue(mockRental);
|
|
});
|
|
|
|
it('should allow owner to mark rental as completed', async () => {
|
|
const completedRental = { ...mockRental, status: 'completed' };
|
|
mockRentalFindByPk
|
|
.mockResolvedValueOnce(mockRental)
|
|
.mockResolvedValueOnce(completedRental);
|
|
|
|
const response = await request(app)
|
|
.post('/rentals/1/mark-completed');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockRental.update).toHaveBeenCalledWith({ status: 'completed' });
|
|
});
|
|
|
|
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-completed');
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body).toEqual({
|
|
error: 'Only owners can mark rentals as completed'
|
|
});
|
|
});
|
|
|
|
it('should return 400 for invalid status', async () => {
|
|
const pendingRental = { ...mockRental, status: 'pending' };
|
|
mockRentalFindByPk.mockResolvedValue(pendingRental);
|
|
|
|
const response = await request(app)
|
|
.post('/rentals/1/mark-completed');
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({
|
|
error: 'Can only mark active or confirmed rentals as completed',
|
|
});
|
|
});
|
|
});
|
|
|
|
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',
|
|
],
|
|
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(400);
|
|
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(400);
|
|
expect(response.body).toEqual({ error: 'Cannot cancel completed rental' });
|
|
});
|
|
});
|
|
}); |