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/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(), }, rentalReminder: { sendUpcomingRentalReminder: 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(), })); jest.mock('../../../services/stripeWebhookService', () => ({ reconcilePayoutStatuses: jest.fn().mockResolvedValue(), })); 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); // 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' }); }); }); });