/** * Rental Integration Tests * * These tests use a real database connection to verify the complete * rental lifecycle including creation, approval, completion, and * cancellation flows. */ const request = require('supertest'); const express = require('express'); const cookieParser = require('cookie-parser'); const jwt = require('jsonwebtoken'); const { sequelize, User, Item, Rental } = require('../../models'); const rentalRoutes = require('../../routes/rentals'); // Test app setup const createTestApp = () => { const app = express(); app.use(express.json()); app.use(cookieParser()); // Add request ID middleware app.use((req, res, next) => { req.id = 'test-request-id'; next(); }); app.use('/rentals', rentalRoutes); return app; }; // Generate auth token for user const generateAuthToken = (user) => { return jwt.sign( { id: user.id, jwtVersion: user.jwtVersion || 0 }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' } ); }; // Test data factories const createTestUser = async (overrides = {}) => { const defaultData = { email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`, password: 'TestPassword123!', firstName: 'Test', lastName: 'User', isVerified: true, authProvider: 'local', }; return User.create({ ...defaultData, ...overrides }); }; const createTestItem = async (ownerId, overrides = {}) => { const defaultData = { name: 'Test Item', description: 'A test item for rental', pricePerDay: 25.00, pricePerHour: 5.00, replacementCost: 500.00, condition: 'excellent', isAvailable: true, pickUpAvailable: true, ownerId, city: 'Test City', state: 'California', }; return Item.create({ ...defaultData, ...overrides }); }; const createTestRental = async (itemId, renterId, ownerId, overrides = {}) => { const now = new Date(); const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); const defaultData = { itemId, renterId, ownerId, startDateTime: tomorrow, endDateTime: new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000), // Use free rentals to avoid Stripe payment requirements in tests totalAmount: 0, platformFee: 0, payoutAmount: 0, status: 'pending', paymentStatus: 'pending', deliveryMethod: 'pickup', }; return Rental.create({ ...defaultData, ...overrides }); }; describe('Rental Integration Tests', () => { let app; let owner; let renter; let item; beforeAll(async () => { // Set test environment variables process.env.NODE_ENV = 'test'; process.env.JWT_ACCESS_SECRET = 'test-access-secret'; process.env.JWT_REFRESH_SECRET = 'test-refresh-secret'; // Sync database await sequelize.sync({ force: true }); app = createTestApp(); }); afterAll(async () => { await sequelize.close(); }); beforeEach(async () => { // Clean up in correct order (respecting foreign key constraints) await Rental.destroy({ where: {}, truncate: true, cascade: true }); await Item.destroy({ where: {}, truncate: true, cascade: true }); await User.destroy({ where: {}, truncate: true, cascade: true }); // Create test users owner = await createTestUser({ email: 'owner@example.com', firstName: 'Item', lastName: 'Owner', stripeConnectedAccountId: 'acct_test_owner', }); renter = await createTestUser({ email: 'renter@example.com', firstName: 'Item', lastName: 'Renter', }); // Create test item item = await createTestItem(owner.id); }); describe('GET /rentals/renting', () => { it('should return rentals where user is the renter', async () => { // Create a rental where renter is the renter await createTestRental(item.id, renter.id, owner.id); const token = generateAuthToken(renter); const response = await request(app) .get('/rentals/renting') .set('Cookie', [`accessToken=${token}`]) .expect(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBe(1); expect(response.body[0].renterId).toBe(renter.id); }); it('should return empty array for user with no rentals', async () => { const token = generateAuthToken(renter); const response = await request(app) .get('/rentals/renting') .set('Cookie', [`accessToken=${token}`]) .expect(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBe(0); }); it('should require authentication', async () => { const response = await request(app) .get('/rentals/renting') .expect(401); expect(response.body.code).toBeDefined(); }); }); describe('GET /rentals/owning', () => { it('should return rentals where user is the owner', async () => { // Create a rental where owner is the item owner await createTestRental(item.id, renter.id, owner.id); const token = generateAuthToken(owner); const response = await request(app) .get('/rentals/owning') .set('Cookie', [`accessToken=${token}`]) .expect(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBe(1); expect(response.body[0].ownerId).toBe(owner.id); }); }); describe('PUT /rentals/:id/status', () => { let rental; beforeEach(async () => { rental = await createTestRental(item.id, renter.id, owner.id); }); it('should allow owner to confirm a pending rental', async () => { const token = generateAuthToken(owner); const response = await request(app) .put(`/rentals/${rental.id}/status`) .set('Cookie', [`accessToken=${token}`]) .send({ status: 'confirmed' }) .expect(200); expect(response.body.status).toBe('confirmed'); // Verify in database await rental.reload(); expect(rental.status).toBe('confirmed'); }); it('should allow renter to update status (no owner-only restriction)', async () => { const token = generateAuthToken(renter); const response = await request(app) .put(`/rentals/${rental.id}/status`) .set('Cookie', [`accessToken=${token}`]) .send({ status: 'confirmed' }) .expect(200); // Note: API currently allows both owner and renter to update status // Owner-specific logic (payment processing) only runs for owner await rental.reload(); expect(rental.status).toBe('confirmed'); }); it('should handle confirming already confirmed rental (idempotent)', async () => { // First confirm it await rental.update({ status: 'confirmed' }); const token = generateAuthToken(owner); // API allows re-confirming (idempotent operation) const response = await request(app) .put(`/rentals/${rental.id}/status`) .set('Cookie', [`accessToken=${token}`]) .send({ status: 'confirmed' }) .expect(200); // Status should remain confirmed await rental.reload(); expect(rental.status).toBe('confirmed'); }); }); describe('PUT /rentals/:id/decline', () => { let rental; beforeEach(async () => { rental = await createTestRental(item.id, renter.id, owner.id); }); it('should allow owner to decline a pending rental', async () => { const token = generateAuthToken(owner); const response = await request(app) .put(`/rentals/${rental.id}/decline`) .set('Cookie', [`accessToken=${token}`]) .send({ reason: 'Item not available for those dates' }) .expect(200); expect(response.body.status).toBe('declined'); // Verify in database await rental.reload(); expect(rental.status).toBe('declined'); expect(rental.declineReason).toBe('Item not available for those dates'); }); it('should not allow declining already declined rental', async () => { await rental.update({ status: 'declined' }); const token = generateAuthToken(owner); const response = await request(app) .put(`/rentals/${rental.id}/decline`) .set('Cookie', [`accessToken=${token}`]) .send({ reason: 'Already declined' }) .expect(400); expect(response.body.error).toBeDefined(); }); }); describe('POST /rentals/:id/cancel', () => { let rental; beforeEach(async () => { rental = await createTestRental(item.id, renter.id, owner.id, { status: 'confirmed', paymentStatus: 'paid', }); }); it('should allow renter to cancel their rental', async () => { const token = generateAuthToken(renter); const response = await request(app) .post(`/rentals/${rental.id}/cancel`) .set('Cookie', [`accessToken=${token}`]) .send({ reason: 'Change of plans' }) .expect(200); // Response format is { rental: {...}, refund: {...} } expect(response.body.rental.status).toBe('cancelled'); expect(response.body.rental.cancelledBy).toBe('renter'); // Verify in database await rental.reload(); expect(rental.status).toBe('cancelled'); expect(rental.cancelledBy).toBe('renter'); expect(rental.cancelledAt).toBeDefined(); }); it('should allow owner to cancel their rental', async () => { const token = generateAuthToken(owner); const response = await request(app) .post(`/rentals/${rental.id}/cancel`) .set('Cookie', [`accessToken=${token}`]) .send({ reason: 'Item broken' }) .expect(200); expect(response.body.rental.status).toBe('cancelled'); expect(response.body.rental.cancelledBy).toBe('owner'); }); it('should not allow cancelling completed rental', async () => { await rental.update({ status: 'completed', paymentStatus: 'paid' }); const token = generateAuthToken(renter); // RefundService throws error which becomes 500 via next(error) const response = await request(app) .post(`/rentals/${rental.id}/cancel`) .set('Cookie', [`accessToken=${token}`]) .send({ reason: 'Too late' }); // Expect error (could be 400 or 500 depending on error middleware) expect(response.status).toBeGreaterThanOrEqual(400); }); it('should not allow unauthorized user to cancel rental', async () => { const otherUser = await createTestUser({ email: 'other@example.com' }); const token = generateAuthToken(otherUser); const response = await request(app) .post(`/rentals/${rental.id}/cancel`) .set('Cookie', [`accessToken=${token}`]) .send({ reason: 'Not my rental' }); // Expect error (could be 403 or 500 depending on error middleware) expect(response.status).toBeGreaterThanOrEqual(400); }); }); describe('GET /rentals/pending-requests-count', () => { it('should return count of pending rental requests for owner', async () => { // Create multiple pending rentals await createTestRental(item.id, renter.id, owner.id, { status: 'pending' }); await createTestRental(item.id, renter.id, owner.id, { status: 'pending' }); await createTestRental(item.id, renter.id, owner.id, { status: 'confirmed' }); const token = generateAuthToken(owner); const response = await request(app) .get('/rentals/pending-requests-count') .set('Cookie', [`accessToken=${token}`]) .expect(200); expect(response.body.count).toBe(2); }); it('should return 0 for user with no pending requests', async () => { const token = generateAuthToken(renter); const response = await request(app) .get('/rentals/pending-requests-count') .set('Cookie', [`accessToken=${token}`]) .expect(200); expect(response.body.count).toBe(0); }); }); describe('Rental Lifecycle', () => { it('should complete full rental lifecycle: pending -> confirmed -> active -> completed', async () => { // Create pending free rental (totalAmount: 0 is default) const rental = await createTestRental(item.id, renter.id, owner.id, { status: 'pending', startDateTime: new Date(Date.now() - 60 * 60 * 1000), // Started 1 hour ago endDateTime: new Date(Date.now() + 60 * 60 * 1000), // Ends in 1 hour }); const ownerToken = generateAuthToken(owner); // Step 1: Owner confirms rental (works for free rentals) let response = await request(app) .put(`/rentals/${rental.id}/status`) .set('Cookie', [`accessToken=${ownerToken}`]) .send({ status: 'confirmed' }) .expect(200); expect(response.body.status).toBe('confirmed'); // Step 2: Rental becomes active (typically done by system/webhook) await rental.update({ status: 'active' }); // Verify status await rental.reload(); expect(rental.status).toBe('active'); // Step 3: Owner marks rental as completed response = await request(app) .post(`/rentals/${rental.id}/mark-completed`) .set('Cookie', [`accessToken=${ownerToken}`]) .expect(200); expect(response.body.status).toBe('completed'); // Verify final state await rental.reload(); expect(rental.status).toBe('completed'); }); }); describe('Review System', () => { let completedRental; beforeEach(async () => { completedRental = await createTestRental(item.id, renter.id, owner.id, { status: 'completed', paymentStatus: 'paid', }); }); it('should allow renter to review item', async () => { const token = generateAuthToken(renter); const response = await request(app) .post(`/rentals/${completedRental.id}/review-item`) .set('Cookie', [`accessToken=${token}`]) .send({ rating: 5, review: 'Great item, worked perfectly!', }) .expect(200); expect(response.body.success).toBe(true); // Verify in database await completedRental.reload(); expect(completedRental.itemRating).toBe(5); expect(completedRental.itemReview).toBe('Great item, worked perfectly!'); expect(completedRental.itemReviewSubmittedAt).toBeDefined(); }); it('should allow owner to review renter', async () => { const token = generateAuthToken(owner); const response = await request(app) .post(`/rentals/${completedRental.id}/review-renter`) .set('Cookie', [`accessToken=${token}`]) .send({ rating: 4, review: 'Good renter, returned on time.', }) .expect(200); expect(response.body.success).toBe(true); // Verify in database await completedRental.reload(); expect(completedRental.renterRating).toBe(4); expect(completedRental.renterReview).toBe('Good renter, returned on time.'); }); it('should not allow review of non-completed rental', async () => { const pendingRental = await createTestRental(item.id, renter.id, owner.id, { status: 'pending', }); const token = generateAuthToken(renter); const response = await request(app) .post(`/rentals/${pendingRental.id}/review-item`) .set('Cookie', [`accessToken=${token}`]) .send({ rating: 5, review: 'Cannot review yet', }) .expect(400); expect(response.body.error).toBeDefined(); }); it('should not allow duplicate reviews', async () => { // First review await completedRental.update({ itemRating: 5, itemReview: 'First review', itemReviewSubmittedAt: new Date(), }); const token = generateAuthToken(renter); const response = await request(app) .post(`/rentals/${completedRental.id}/review-item`) .set('Cookie', [`accessToken=${token}`]) .send({ rating: 3, review: 'Second review attempt', }) .expect(400); expect(response.body.error).toContain('already'); }); }); describe('Database Constraints', () => { it('should not allow rental with invalid item ID', async () => { await expect( createTestRental('00000000-0000-0000-0000-000000000000', renter.id, owner.id) ).rejects.toThrow(); }); it('should not allow rental with invalid user IDs', async () => { await expect( createTestRental(item.id, '00000000-0000-0000-0000-000000000000', owner.id) ).rejects.toThrow(); }); it('should cascade delete rentals when item is deleted', async () => { const rental = await createTestRental(item.id, renter.id, owner.id); // Delete the item await item.destroy(); // Rental should also be deleted (due to foreign key constraint) const deletedRental = await Rental.findByPk(rental.id); expect(deletedRental).toBeNull(); }); }); describe('Concurrent Operations', () => { it('should handle concurrent status updates (last write wins)', async () => { const rental = await createTestRental(item.id, renter.id, owner.id, { status: 'pending', }); const ownerToken = generateAuthToken(owner); // Simulate concurrent confirm and decline requests const [confirmResult, declineResult] = await Promise.allSettled([ request(app) .put(`/rentals/${rental.id}/status`) .set('Cookie', [`accessToken=${ownerToken}`]) .send({ status: 'confirmed' }), request(app) .put(`/rentals/${rental.id}/decline`) .set('Cookie', [`accessToken=${ownerToken}`]) .send({ reason: 'Declining instead' }), ]); // Both requests may succeed (no optimistic locking) // Verify rental ends up in a valid state await rental.reload(); expect(['confirmed', 'declined']).toContain(rental.status); // At least one should have succeeded const successes = [confirmResult, declineResult].filter( r => r.status === 'fulfilled' && r.value.status === 200 ); expect(successes.length).toBeGreaterThanOrEqual(1); }); }); });