604 lines
18 KiB
JavaScript
604 lines
18 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|