/** * 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.0, pricePerHour: 5.0, replacementCost: 500.0, 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); // 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 is now "active" because status is confirmed and startDateTime has passed. // "active" is a computed status, not stored. The stored status remains "confirmed" await rental.reload(); expect(rental.status).toBe("confirmed"); // Stored status is still 'confirmed' // isActive() returns true because status='confirmed' and startDateTime is in the past // Step 3: Owner marks rental as completed (via mark-return with status='returned') response = await request(app) .post(`/rentals/${rental.id}/mark-return`) .set("Cookie", [`accessToken=${ownerToken}`]) .send({ status: "returned" }) .expect(200); expect(response.body.rental.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); }); }); });