imageFilenames and imageFilename, backend integration tests, frontend tests, removed username references

This commit is contained in:
jackiettran
2025-11-26 23:13:23 -05:00
parent f2d3aac029
commit 11593606aa
52 changed files with 2815 additions and 150 deletions

View File

@@ -0,0 +1,621 @@
/**
* Authentication Integration Tests
*
* These tests use a real database connection to verify the complete
* authentication flow including user registration, login, token management,
* and password reset functionality.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
// Mock rate limiters before importing routes
jest.mock('../../middleware/rateLimiter', () => ({
registerLimiter: (req, res, next) => next(),
loginLimiter: (req, res, next) => next(),
refreshLimiter: (req, res, next) => next(),
passwordResetLimiter: (req, res, next) => next(),
passwordResetRequestLimiter: (req, res, next) => next(),
verifyEmailLimiter: (req, res, next) => next(),
resendVerificationLimiter: (req, res, next) => next(),
}));
// Mock CSRF protection for tests
jest.mock('../../middleware/csrf', () => ({
csrfProtection: (req, res, next) => next(),
getCSRFToken: (req, res) => {
res.set('x-csrf-token', 'test-csrf-token');
res.json({ csrfToken: 'test-csrf-token' });
},
}));
const { sequelize, User, AlphaInvitation } = require('../../models');
const authRoutes = require('../../routes/auth');
// 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('/auth', authRoutes);
return app;
};
// Test data factory
const createTestUser = async (overrides = {}) => {
const defaultData = {
email: `test-${Date.now()}@example.com`,
password: 'TestPassword123!',
firstName: 'Test',
lastName: 'User',
isVerified: false,
authProvider: 'local',
};
return User.create({ ...defaultData, ...overrides });
};
const createAlphaInvitation = async (overrides = {}) => {
// Generate a valid code matching pattern /^ALPHA-[A-Z0-9]{8}$/i
const randomCode = Math.random().toString(36).substring(2, 10).toUpperCase().padEnd(8, 'X');
const defaultData = {
code: `ALPHA-${randomCode.substring(0, 8)}`,
email: `alpha-${Date.now()}@example.com`, // Email is required
status: 'pending', // Valid values: pending, active, revoked
};
return AlphaInvitation.create({ ...defaultData, ...overrides });
};
describe('Auth Integration Tests', () => {
let app;
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';
process.env.ALPHA_TESTING_ENABLED = 'false';
// Sync database
await sequelize.sync({ force: true });
app = createTestApp();
});
afterAll(async () => {
await sequelize.close();
});
beforeEach(async () => {
// Clean up users before each test
await User.destroy({ where: {}, truncate: true, cascade: true });
await AlphaInvitation.destroy({ where: {}, truncate: true, cascade: true });
});
describe('POST /auth/register', () => {
it('should register a new user successfully', async () => {
const userData = {
email: 'newuser@example.com',
password: 'SecurePassword123!',
firstName: 'New',
lastName: 'User',
};
const response = await request(app)
.post('/auth/register')
.send(userData)
.expect(201);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe(userData.email);
expect(response.body.user.firstName).toBe(userData.firstName);
expect(response.body.user.isVerified).toBe(false);
// Verify user was created in database
const user = await User.findOne({ where: { email: userData.email } });
expect(user).not.toBeNull();
expect(user.firstName).toBe(userData.firstName);
// Verify password was hashed
expect(user.password).not.toBe(userData.password);
// Verify cookies were set
expect(response.headers['set-cookie']).toBeDefined();
const cookies = response.headers['set-cookie'];
expect(cookies.some(c => c.startsWith('accessToken='))).toBe(true);
expect(cookies.some(c => c.startsWith('refreshToken='))).toBe(true);
});
it('should reject registration with existing email', async () => {
await createTestUser({ email: 'existing@example.com' });
const response = await request(app)
.post('/auth/register')
.send({
email: 'existing@example.com',
password: 'SecurePassword123!',
firstName: 'Another',
lastName: 'User',
})
.expect(400);
expect(response.body.error).toBe('Registration failed');
expect(response.body.details[0].field).toBe('email');
});
it('should reject registration with invalid email format', async () => {
const response = await request(app)
.post('/auth/register')
.send({
email: 'not-an-email',
password: 'SecurePassword123!',
firstName: 'Test',
lastName: 'User',
})
.expect(400);
// Response should contain errors or error message
expect(response.body.errors || response.body.error).toBeDefined();
});
it('should generate verification token on registration', async () => {
const userData = {
email: 'verify@example.com',
password: 'SecurePassword123!',
firstName: 'Verify',
lastName: 'User',
};
await request(app)
.post('/auth/register')
.send(userData)
.expect(201);
const user = await User.findOne({ where: { email: userData.email } });
expect(user.verificationToken).toBeDefined();
expect(user.verificationTokenExpiry).toBeDefined();
});
});
describe('POST /auth/login', () => {
let testUser;
beforeEach(async () => {
testUser = await createTestUser({
email: 'login@example.com',
password: 'TestPassword123!',
isVerified: true,
});
});
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'TestPassword123!',
})
.expect(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('login@example.com');
// Verify cookies were set
const cookies = response.headers['set-cookie'];
expect(cookies.some(c => c.startsWith('accessToken='))).toBe(true);
expect(cookies.some(c => c.startsWith('refreshToken='))).toBe(true);
});
it('should reject login with wrong password', async () => {
const response = await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'WrongPassword!',
})
.expect(401);
expect(response.body.error).toBe('Invalid credentials');
});
it('should reject login with non-existent email', async () => {
const response = await request(app)
.post('/auth/login')
.send({
email: 'nonexistent@example.com',
password: 'SomePassword123!',
})
.expect(401);
expect(response.body.error).toBe('Invalid credentials');
});
it('should increment login attempts on failed login', async () => {
await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'WrongPassword!',
})
.expect(401);
const user = await User.findOne({ where: { email: 'login@example.com' } });
expect(user.loginAttempts).toBe(1);
});
it('should lock account after too many failed attempts', async () => {
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'WrongPassword!',
});
}
// 6th attempt should return locked error
const response = await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'TestPassword123!', // Correct password
})
.expect(423);
expect(response.body.error).toContain('Account is temporarily locked');
});
it('should reset login attempts on successful login', async () => {
// First fail a login
await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'WrongPassword!',
});
// Verify attempts incremented
let user = await User.findOne({ where: { email: 'login@example.com' } });
expect(user.loginAttempts).toBe(1);
// Now login successfully
await request(app)
.post('/auth/login')
.send({
email: 'login@example.com',
password: 'TestPassword123!',
})
.expect(200);
// Verify attempts reset
user = await User.findOne({ where: { email: 'login@example.com' } });
expect(user.loginAttempts).toBe(0);
});
});
describe('POST /auth/logout', () => {
it('should clear cookies on logout', async () => {
const response = await request(app)
.post('/auth/logout')
.expect(200);
expect(response.body.message).toBe('Logged out successfully');
// Verify cookies are cleared
const cookies = response.headers['set-cookie'];
expect(cookies.some(c => c.includes('accessToken=;'))).toBe(true);
expect(cookies.some(c => c.includes('refreshToken=;'))).toBe(true);
});
});
describe('POST /auth/refresh', () => {
let testUser;
beforeEach(async () => {
testUser = await createTestUser({
email: 'refresh@example.com',
isVerified: true,
});
});
it('should refresh access token with valid refresh token', async () => {
// Create a valid refresh token
const refreshToken = jwt.sign(
{ id: testUser.id, jwtVersion: testUser.jwtVersion, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
const response = await request(app)
.post('/auth/refresh')
.set('Cookie', [`refreshToken=${refreshToken}`])
.expect(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('refresh@example.com');
// Verify new access token cookie was set
const cookies = response.headers['set-cookie'];
expect(cookies.some(c => c.startsWith('accessToken='))).toBe(true);
});
it('should reject refresh without token', async () => {
const response = await request(app)
.post('/auth/refresh')
.expect(401);
expect(response.body.error).toBe('Refresh token required');
});
it('should reject refresh with invalid token', async () => {
const response = await request(app)
.post('/auth/refresh')
.set('Cookie', ['refreshToken=invalid-token'])
.expect(401);
expect(response.body.error).toBe('Invalid or expired refresh token');
});
it('should reject refresh with outdated JWT version', async () => {
// Create refresh token with old JWT version
const refreshToken = jwt.sign(
{ id: testUser.id, jwtVersion: testUser.jwtVersion - 1, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
const response = await request(app)
.post('/auth/refresh')
.set('Cookie', [`refreshToken=${refreshToken}`])
.expect(401);
expect(response.body.code).toBe('JWT_VERSION_MISMATCH');
});
});
describe('GET /auth/status', () => {
let testUser;
beforeEach(async () => {
testUser = await createTestUser({
email: 'status@example.com',
isVerified: true,
});
});
it('should return authenticated status with valid token', async () => {
const accessToken = jwt.sign(
{ id: testUser.id, jwtVersion: testUser.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
const response = await request(app)
.get('/auth/status')
.set('Cookie', [`accessToken=${accessToken}`])
.expect(200);
expect(response.body.authenticated).toBe(true);
expect(response.body.user.email).toBe('status@example.com');
});
it('should return unauthenticated status without token', async () => {
const response = await request(app)
.get('/auth/status')
.expect(200);
expect(response.body.authenticated).toBe(false);
});
});
describe('POST /auth/verify-email', () => {
let testUser;
let verificationToken;
beforeEach(async () => {
testUser = await createTestUser({
email: 'unverified@example.com',
isVerified: false,
});
await testUser.generateVerificationToken();
await testUser.reload();
verificationToken = testUser.verificationToken;
});
it('should verify email with valid token', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: verificationToken })
.expect(200);
expect(response.body.message).toBe('Email verified successfully');
expect(response.body.user.isVerified).toBe(true);
// Verify in database
await testUser.reload();
expect(testUser.isVerified).toBe(true);
expect(testUser.verificationToken).toBeNull();
});
it('should reject verification with invalid token', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'invalid-token' })
.expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
});
it('should reject verification for already verified user', async () => {
// First verify the user
await testUser.verifyEmail();
const response = await request(app)
.post('/auth/verify-email')
.send({ token: verificationToken })
.expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
});
});
describe('Password Reset Flow', () => {
let testUser;
beforeEach(async () => {
testUser = await createTestUser({
email: 'reset@example.com',
isVerified: true,
authProvider: 'local',
});
});
describe('POST /auth/forgot-password', () => {
it('should accept valid email and generate reset token', async () => {
const response = await request(app)
.post('/auth/forgot-password')
.send({ email: 'reset@example.com' })
.expect(200);
expect(response.body.message).toContain('If an account exists');
// Verify token was generated in database
await testUser.reload();
expect(testUser.passwordResetToken).toBeDefined();
expect(testUser.passwordResetTokenExpiry).toBeDefined();
});
it('should return success even for non-existent email (security)', async () => {
const response = await request(app)
.post('/auth/forgot-password')
.send({ email: 'nonexistent@example.com' })
.expect(200);
expect(response.body.message).toContain('If an account exists');
});
});
describe('POST /auth/reset-password', () => {
let resetToken;
beforeEach(async () => {
resetToken = await testUser.generatePasswordResetToken();
});
it('should reset password with valid token', async () => {
const newPassword = 'NewSecurePassword123!';
const response = await request(app)
.post('/auth/reset-password')
.send({ token: resetToken, newPassword })
.expect(200);
expect(response.body.message).toContain('Password has been reset');
// Verify password was changed
await testUser.reload();
const isValid = await testUser.comparePassword(newPassword);
expect(isValid).toBe(true);
// Verify token was cleared
expect(testUser.passwordResetToken).toBeNull();
});
it('should reject reset with invalid token', async () => {
const response = await request(app)
.post('/auth/reset-password')
.send({ token: 'invalid-token', newPassword: 'NewPassword123!' })
.expect(400);
// Response should contain error (format may vary based on validation)
expect(response.body.error || response.body.errors).toBeDefined();
});
it('should increment JWT version after password reset', async () => {
const oldJwtVersion = testUser.jwtVersion;
await request(app)
.post('/auth/reset-password')
.send({ token: resetToken, newPassword: 'NewPassword123!' })
.expect(200);
await testUser.reload();
expect(testUser.jwtVersion).toBe(oldJwtVersion + 1);
});
});
});
describe('CSRF Token', () => {
it('should return CSRF token', async () => {
const response = await request(app)
.get('/auth/csrf-token')
.expect(200);
expect(response.headers['x-csrf-token']).toBeDefined();
});
});
describe('Alpha Testing Mode', () => {
beforeEach(() => {
process.env.ALPHA_TESTING_ENABLED = 'true';
});
afterEach(() => {
process.env.ALPHA_TESTING_ENABLED = 'false';
});
it('should reject registration without alpha code when enabled', async () => {
const response = await request(app)
.post('/auth/register')
.send({
email: 'alpha@example.com',
password: 'SecurePassword123!',
firstName: 'Alpha',
lastName: 'User',
})
.expect(403);
expect(response.body.error).toContain('Alpha access required');
});
it('should allow registration with valid alpha code', async () => {
const validCode = 'ALPHA-TEST1234';
const invitation = await createAlphaInvitation({
code: validCode,
email: 'invited@example.com', // Required field
});
// Cookie-parser parses JSON cookies that start with 'j:'
const cookieValue = `j:${JSON.stringify({ code: validCode })}`;
const response = await request(app)
.post('/auth/register')
.set('Cookie', [`alphaAccessCode=${cookieValue}`])
.send({
email: 'alphauser@example.com',
password: 'SecurePassword123!',
firstName: 'Alpha',
lastName: 'User',
})
.expect(201);
expect(response.body.user.email).toBe('alphauser@example.com');
// Verify invitation was linked
await invitation.reload();
expect(invitation.usedBy).toBeDefined();
expect(invitation.status).toBe('active');
});
});
});

View 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);
});
});
});

View File

@@ -319,7 +319,7 @@ describe('Auth Routes', () => {
email: 'test@gmail.com',
firstName: 'Test',
lastName: 'User',
profileImage: 'profile.jpg'
imageFilename: 'profile.jpg'
};
User.create.mockResolvedValue(newUser);
@@ -338,7 +338,7 @@ describe('Auth Routes', () => {
lastName: 'User',
authProvider: 'google',
providerId: 'google123',
profileImage: 'profile.jpg',
imageFilename: 'profile.jpg',
username: 'test_gle123'
});
});
@@ -785,7 +785,7 @@ describe('Auth Routes', () => {
email: 'oauth@gmail.com',
firstName: 'OAuth',
lastName: 'User',
profileImage: 'pic.jpg',
imageFilename: 'pic.jpg',
isVerified: true
};

View File

@@ -166,7 +166,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'username', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName']
}
],
limit: 20,
@@ -608,7 +608,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'username', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName']
}
]
});
@@ -679,7 +679,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'username', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName']
}
]
});

View File

@@ -63,7 +63,7 @@ describe('Messages Routes', () => {
id: 2,
firstName: 'Jane',
lastName: 'Smith',
profileImage: 'jane.jpg'
imageFilename: 'jane.jpg'
}
},
{
@@ -77,7 +77,7 @@ describe('Messages Routes', () => {
id: 3,
firstName: 'Bob',
lastName: 'Johnson',
profileImage: null
imageFilename: null
}
}
];
@@ -95,7 +95,7 @@ describe('Messages Routes', () => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -127,7 +127,7 @@ describe('Messages Routes', () => {
id: 2,
firstName: 'Jane',
lastName: 'Smith',
profileImage: 'jane.jpg'
imageFilename: 'jane.jpg'
}
}
];
@@ -145,7 +145,7 @@ describe('Messages Routes', () => {
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -175,13 +175,13 @@ describe('Messages Routes', () => {
id: 2,
firstName: 'Jane',
lastName: 'Smith',
profileImage: 'jane.jpg'
imageFilename: 'jane.jpg'
},
receiver: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
imageFilename: 'john.jpg'
},
update: jest.fn()
};
@@ -208,13 +208,13 @@ describe('Messages Routes', () => {
id: 2,
firstName: 'Jane',
lastName: 'Smith',
profileImage: 'jane.jpg'
imageFilename: 'jane.jpg'
},
receiver: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
imageFilename: 'john.jpg'
}
});
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
@@ -239,13 +239,13 @@ describe('Messages Routes', () => {
id: 2,
firstName: 'Jane',
lastName: 'Smith',
profileImage: 'jane.jpg'
imageFilename: 'jane.jpg'
},
receiver: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
imageFilename: 'john.jpg'
}
});
expect(mockMessage.update).not.toHaveBeenCalled();
@@ -304,7 +304,7 @@ describe('Messages Routes', () => {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
imageFilename: 'john.jpg'
}
};
@@ -330,7 +330,7 @@ describe('Messages Routes', () => {
senderId: 1,
receiverId: 2,
content: 'Hello Jane!',
imagePath: null
imageFilename: null
});
});

View File

@@ -131,7 +131,7 @@ describe('Rentals Routes', () => {
{
model: User,
as: 'owner',
attributes: ['id', 'username', 'firstName', 'lastName'],
attributes: ['id', 'firstName', 'lastName'],
},
],
order: [['createdAt', 'DESC']],
@@ -174,7 +174,7 @@ describe('Rentals Routes', () => {
{
model: User,
as: 'renter',
attributes: ['id', 'username', 'firstName', 'lastName'],
attributes: ['id', 'firstName', 'lastName'],
},
],
order: [['createdAt', 'DESC']],

View File

@@ -71,7 +71,7 @@ describe('Users Routes', () => {
lastName: 'Doe',
email: 'john@example.com',
phone: '555-1234',
profileImage: 'profile.jpg',
imageFilename: 'profile.jpg',
};
mockUserFindByPk.mockResolvedValue(mockUser);
@@ -397,7 +397,7 @@ describe('Users Routes', () => {
firstName: 'Jane',
lastName: 'Smith',
username: 'janesmith',
profileImage: 'jane.jpg',
imageFilename: 'jane.jpg',
};
mockUserFindByPk.mockResolvedValue(mockUser);
@@ -536,7 +536,7 @@ describe('Users Routes', () => {
describe('POST /profile/image', () => {
const mockUser = {
id: 1,
profileImage: 'old-image.jpg',
imageFilename: 'old-image.jpg',
update: jest.fn(),
};
@@ -559,7 +559,7 @@ describe('Users Routes', () => {
});
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
expect(mockUser.update).toHaveBeenCalledWith({
profileImage: 'test-profile.jpg'
imageFilename: 'test-profile.jpg'
});
});
@@ -617,7 +617,7 @@ describe('Users Routes', () => {
const userWithoutImage = {
id: 1,
profileImage: null,
imageFilename: null,
update: jest.fn().mockResolvedValue()
};
mockUserFindByPk.mockResolvedValue(userWithoutImage);
@@ -638,7 +638,7 @@ describe('Users Routes', () => {
const userWithImage = {
id: 1,
profileImage: 'old-image.jpg',
imageFilename: 'old-image.jpg',
update: jest.fn().mockResolvedValue()
};
mockUserFindByPk.mockResolvedValue(userWithImage);

View File

@@ -152,7 +152,7 @@ describe('ConditionCheckService', () => {
include: [{
model: User,
as: 'submittedByUser',
attributes: ['id', 'username', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName']
}],
order: [['submittedAt', 'ASC']]
});