imageFilenames and imageFilename, backend integration tests, frontend tests, removed username references
This commit is contained in:
585
backend/tests/integration/rental.integration.test.js
Normal file
585
backend/tests/integration/rental.integration.test.js
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user