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,19 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Change images column from VARCHAR(255)[] to TEXT[] to support longer URLs
await queryInterface.changeColumn("Items", "images", {
type: Sequelize.ARRAY(Sequelize.TEXT),
defaultValue: [],
});
},
down: async (queryInterface, Sequelize) => {
// Revert to original VARCHAR(255)[] - note: this may fail if data exceeds 255 chars
await queryInterface.changeColumn("Items", "images", {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
});
},
};

View File

@@ -0,0 +1,39 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Change image/photo URL fields from VARCHAR(255) to TEXT to support longer URLs
await Promise.all([
queryInterface.changeColumn("Users", "profileImage", {
type: Sequelize.TEXT,
allowNull: true,
}),
queryInterface.changeColumn("Messages", "imagePath", {
type: Sequelize.TEXT,
allowNull: true,
}),
queryInterface.changeColumn("ConditionChecks", "photos", {
type: Sequelize.ARRAY(Sequelize.TEXT),
defaultValue: [],
}),
]);
},
down: async (queryInterface, Sequelize) => {
// Revert to original VARCHAR(255) - note: this may fail if data exceeds 255 chars
await Promise.all([
queryInterface.changeColumn("Users", "profileImage", {
type: Sequelize.STRING,
allowNull: true,
}),
queryInterface.changeColumn("Messages", "imagePath", {
type: Sequelize.STRING,
allowNull: true,
}),
queryInterface.changeColumn("ConditionChecks", "photos", {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
}),
]);
},
};

View File

@@ -0,0 +1,24 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Rename image fields to consistent naming convention
// Using TEXT type for all to support long URLs/paths
await queryInterface.renameColumn("Items", "images", "imageFilenames");
await queryInterface.renameColumn("Users", "profileImage", "imageFilename");
await queryInterface.renameColumn("Messages", "imagePath", "imageFilename");
await queryInterface.renameColumn("ConditionChecks", "photos", "imageFilenames");
await queryInterface.renameColumn("ForumPosts", "images", "imageFilenames");
await queryInterface.renameColumn("ForumComments", "images", "imageFilenames");
},
down: async (queryInterface, Sequelize) => {
// Revert to original column names
await queryInterface.renameColumn("Items", "imageFilenames", "images");
await queryInterface.renameColumn("Users", "imageFilename", "profileImage");
await queryInterface.renameColumn("Messages", "imageFilename", "imagePath");
await queryInterface.renameColumn("ConditionChecks", "imageFilenames", "photos");
await queryInterface.renameColumn("ForumPosts", "imageFilenames", "images");
await queryInterface.renameColumn("ForumComments", "imageFilenames", "images");
},
};

View File

@@ -24,8 +24,8 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
),
allowNull: false,
},
photos: {
type: DataTypes.ARRAY(DataTypes.STRING),
imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT),
defaultValue: [],
},
notes: {

View File

@@ -39,7 +39,7 @@ const ForumComment = sequelize.define('ForumComment', {
type: DataTypes.BOOLEAN,
defaultValue: false
},
images: {
imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true,
defaultValue: []

View File

@@ -52,7 +52,7 @@ const ForumPost = sequelize.define('ForumPost', {
key: 'id'
}
},
images: {
imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true,
defaultValue: []

View File

@@ -82,8 +82,8 @@ const Item = sequelize.define("Item", {
longitude: {
type: DataTypes.DECIMAL(11, 8),
},
images: {
type: DataTypes.ARRAY(DataTypes.STRING),
imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT),
defaultValue: [],
},
isAvailable: {

View File

@@ -31,8 +31,8 @@ const Message = sequelize.define('Message', {
type: DataTypes.BOOLEAN,
defaultValue: false
},
imagePath: {
type: DataTypes.STRING,
imageFilename: {
type: DataTypes.TEXT,
allowNull: true
}
}, {

View File

@@ -60,8 +60,8 @@ const User = sequelize.define(
country: {
type: DataTypes.STRING,
},
profileImage: {
type: DataTypes.STRING,
imageFilename: {
type: DataTypes.TEXT,
},
isVerified: {
type: DataTypes.BOOLEAN,

View File

@@ -101,12 +101,14 @@ router.post(
phone,
});
// Link alpha invitation to user
// Link alpha invitation to user (only if alpha testing is enabled)
if (alphaInvitation) {
await alphaInvitation.update({
usedBy: user.id,
usedAt: new Date(),
status: "active",
});
}
// Generate verification token and send email
await user.generateVerificationToken();
@@ -367,7 +369,7 @@ router.post(
lastName,
authProvider: "google",
providerId: googleId,
profileImage: picture,
imageFilename: picture,
isVerified: true,
verifiedAt: new Date(),
});
@@ -434,7 +436,7 @@ router.post(
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
profileImage: user.profileImage,
imageFilename: user.imageFilename,
isVerified: user.isVerified,
role: user.role,
},

View File

@@ -27,7 +27,7 @@ const upload = multer({
router.post(
"/:rentalId",
authenticateToken,
upload.array("photos"),
upload.array("imageFilenames"),
async (req, res) => {
try {
const { rentalId } = req.params;
@@ -35,13 +35,13 @@ router.post(
const userId = req.user.id;
// Get uploaded file paths
const photos = req.files ? req.files.map((file) => file.path) : [];
const imageFilenames = req.files ? req.files.map((file) => file.path) : [];
const conditionCheck = await ConditionCheckService.submitConditionCheck(
rentalId,
checkType,
userId,
photos,
imageFilenames,
notes
);
@@ -50,7 +50,7 @@ router.post(
rentalId,
checkType,
userId,
photoCount: photos.length,
photoCount: imageFilenames.length,
});
res.status(201).json({

View File

@@ -21,7 +21,7 @@ const buildCommentTree = (comments, isAdmin = false) => {
// Sanitize deleted comments for non-admin users
if (commentJson.isDeleted && !isAdmin) {
commentJson.content = '';
commentJson.images = [];
commentJson.imageFilenames = [];
}
commentMap[comment.id] = { ...commentJson, replies: [] };
@@ -252,7 +252,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res,
}
// Extract image filenames if uploaded
const images = req.files ? req.files.map(file => file.filename) : [];
const imageFilenames = req.files ? req.files.map(file => file.filename) : [];
// Initialize location fields
let latitude = null;
@@ -313,7 +313,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res,
content,
category,
authorId: req.user.id,
images,
imageFilenames,
zipCode: zipCode || null,
latitude,
longitude
@@ -936,14 +936,14 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
}
// Extract image filenames if uploaded
const images = req.files ? req.files.map(file => file.filename) : [];
const imageFilenames = req.files ? req.files.map(file => file.filename) : [];
const comment = await ForumComment.create({
postId: req.params.id,
authorId: req.user.id,
content,
parentCommentId: parentCommentId || null,
images
imageFilenames
});
// Increment comment count and update post's updatedAt to reflect activity
@@ -955,7 +955,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
@@ -1261,7 +1261,7 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
@@ -1380,7 +1380,7 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
@@ -1512,7 +1512,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
@@ -1545,7 +1545,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
(async () => {
try {
const admin = await User.findByPk(req.user.id, {
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
attributes: ['id', 'firstName', 'lastName', 'email']
});
// Get all unique participants (author + commenters)

View File

@@ -20,7 +20,7 @@ router.get('/', authenticateToken, async (req, res, next) => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -61,12 +61,12 @@ router.get('/conversations', authenticateToken, async (req, res, next) => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
},
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -147,7 +147,7 @@ router.get('/sent', authenticateToken, async (req, res, next) => {
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -186,12 +186,12 @@ router.get('/:id', authenticateToken, async (req, res, next) => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
},
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
]
});
@@ -253,20 +253,20 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res, next) =
}
// Extract image filename if uploaded
const imagePath = req.file ? req.file.filename : null;
const imageFilename = req.file ? req.file.filename : null;
const message = await Message.create({
senderId: req.user.id,
receiverId,
content,
imagePath
imageFilename
});
const messageWithSender = await Message.findByPk(message.id, {
include: [{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}]
});
@@ -398,7 +398,7 @@ router.get('/images/:filename',
// Verify user is sender or receiver of a message with this image
const message = await Message.findOne({
where: {
imagePath: filename,
imageFilename: filename,
[Op.or]: [
{ senderId: req.user.id },
{ receiverId: req.user.id }

View File

@@ -211,8 +211,8 @@ router.post('/profile/image', authenticateToken, (req, res) => {
try {
// Delete old profile image if exists
const user = await User.findByPk(req.user.id);
if (user.profileImage) {
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage);
if (user.imageFilename) {
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.imageFilename);
try {
await fs.unlink(oldImagePath);
} catch (unlinkErr) {
@@ -227,7 +227,7 @@ router.post('/profile/image', authenticateToken, (req, res) => {
// Update user with new image filename
await user.update({
profileImage: req.file.filename
imageFilename: req.file.filename
});
const reqLogger = logger.withRequestId(req.id);

View File

@@ -116,7 +116,7 @@ class ConditionCheckService {
* @param {string} rentalId - Rental ID
* @param {string} checkType - Type of check
* @param {string} userId - User submitting the check
* @param {Array} photos - Array of photo URLs
* @param {Array} imageFilenames - Array of image filenames
* @param {string} notes - Optional notes
* @returns {Object} - Created condition check
*/
@@ -124,7 +124,7 @@ class ConditionCheckService {
rentalId,
checkType,
userId,
photos = [],
imageFilenames = [],
notes = null
) {
// Validate the check
@@ -139,7 +139,7 @@ class ConditionCheckService {
}
// Validate photos (basic validation)
if (photos.length > 20) {
if (imageFilenames.length > 20) {
throw new Error("Maximum 20 photos allowed per condition check");
}
@@ -147,7 +147,7 @@ class ConditionCheckService {
rentalId,
checkType,
submittedBy: userId,
photos,
imageFilenames,
notes,
});
@@ -166,7 +166,7 @@ class ConditionCheckService {
{
model: User,
as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"],
attributes: ["id", "firstName", "lastName"],
},
],
order: [["submittedAt", "ASC"]],
@@ -192,7 +192,7 @@ class ConditionCheckService {
{
model: User,
as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"],
attributes: ["id", "firstName", "lastName"],
},
],
});
@@ -216,7 +216,7 @@ class ConditionCheckService {
status: "completed",
submittedAt: existingCheck.submittedAt,
submittedBy: existingCheck.submittedBy,
photoCount: existingCheck.photos.length,
photoCount: existingCheck.imageFilenames.length,
hasNotes: !!existingCheck.notes,
};
} else {

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']]
});

View File

@@ -6,7 +6,8 @@ module.exports = {
'!src/reportWebVitals.ts',
'!src/**/*.d.ts',
'!src/setupTests.ts',
'!src/test-polyfills.js'
'!src/test-polyfills.js',
'!src/mocks/**'
],
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
@@ -15,7 +16,7 @@ module.exports = {
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
transformIgnorePatterns: [
'node_modules/(?!(axios|@stripe)/)'
'/node_modules/(?!(axios|@stripe)/).*'
],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'

25
frontend/jest.env.js Normal file
View File

@@ -0,0 +1,25 @@
const JSDOMEnvironment = require('jest-environment-jsdom').default;
const { TextEncoder, TextDecoder } = require('util');
class CustomEnvironment extends JSDOMEnvironment {
constructor(config, context) {
super(config, context);
// Add polyfills to global before any test code runs
this.global.TextEncoder = TextEncoder;
this.global.TextDecoder = TextDecoder;
// BroadcastChannel polyfill
this.global.BroadcastChannel = class BroadcastChannel {
constructor(name) {
this.name = name;
}
postMessage() {}
close() {}
addEventListener() {}
removeEventListener() {}
};
}
}
module.exports = CustomEnvironment;

View File

@@ -33,6 +33,7 @@
},
"devDependencies": {
"@types/google.maps": "^3.58.1",
"cross-fetch": "^4.1.0",
"dotenv-cli": "^9.0.0",
"msw": "^2.11.2"
}
@@ -6238,6 +6239,16 @@
"node": ">=10"
}
},
"node_modules/cross-fetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -12174,6 +12185,52 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",

View File

@@ -57,6 +57,7 @@
},
"devDependencies": {
"@types/google.maps": "^3.58.1",
"cross-fetch": "^4.1.0",
"dotenv-cli": "^9.0.0",
"msw": "^2.11.2"
}

View File

@@ -0,0 +1,82 @@
/**
* Manual axios mock for Jest
* This avoids ESM transformation issues with the axios package
*/
const mockAxiosInstance = {
get: jest.fn(() => Promise.resolve({ data: {} })),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
request: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: {
request: {
use: jest.fn(() => 0),
eject: jest.fn(),
clear: jest.fn(),
},
response: {
use: jest.fn(() => 0),
eject: jest.fn(),
clear: jest.fn(),
},
},
defaults: {
headers: {
common: {},
get: {},
post: {},
put: {},
delete: {},
patch: {},
},
baseURL: '',
timeout: 0,
withCredentials: false,
},
getUri: jest.fn(),
head: jest.fn(() => Promise.resolve({ data: {} })),
options: jest.fn(() => Promise.resolve({ data: {} })),
postForm: jest.fn(() => Promise.resolve({ data: {} })),
putForm: jest.fn(() => Promise.resolve({ data: {} })),
patchForm: jest.fn(() => Promise.resolve({ data: {} })),
};
const axios = {
...mockAxiosInstance,
create: jest.fn(() => ({ ...mockAxiosInstance })),
isAxiosError: jest.fn((error: any) => error?.isAxiosError === true),
isCancel: jest.fn(() => false),
all: jest.fn((promises: Promise<any>[]) => Promise.all(promises)),
spread: jest.fn((callback: Function) => (arr: any[]) => callback(...arr)),
toFormData: jest.fn(),
formToJSON: jest.fn(),
CancelToken: {
source: jest.fn(() => ({
token: {},
cancel: jest.fn(),
})),
},
Axios: jest.fn(),
AxiosError: jest.fn(),
Cancel: jest.fn(),
CanceledError: jest.fn(),
VERSION: '1.0.0',
default: mockAxiosInstance,
};
export default axios;
export const AxiosError = class extends Error {
isAxiosError = true;
response?: any;
request?: any;
config?: any;
code?: string;
constructor(message?: string) {
super(message);
this.name = 'AxiosError';
}
};
export type { AxiosRequestConfig } from 'axios';

View File

@@ -0,0 +1,461 @@
import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { AuthProvider, useAuth } from '../../contexts/AuthContext';
import { mockUser } from '../../mocks/handlers';
// Mock the API module
jest.mock('../../services/api', () => {
const mockAuthAPI = {
login: jest.fn(),
register: jest.fn(),
googleLogin: jest.fn(),
logout: jest.fn(),
getStatus: jest.fn(),
};
const mockFetchCSRFToken = jest.fn().mockResolvedValue('test-csrf-token');
const mockResetCSRFToken = jest.fn();
return {
authAPI: mockAuthAPI,
fetchCSRFToken: mockFetchCSRFToken,
resetCSRFToken: mockResetCSRFToken,
};
});
// Get mocked modules
import { authAPI, fetchCSRFToken, resetCSRFToken } from '../../services/api';
const mockAuthAPI = authAPI as jest.Mocked<typeof authAPI>;
const mockFetchCSRFToken = fetchCSRFToken as jest.MockedFunction<typeof fetchCSRFToken>;
const mockResetCSRFToken = resetCSRFToken as jest.MockedFunction<typeof resetCSRFToken>;
// Test component that uses the auth context
const TestComponent: React.FC = () => {
const auth = useAuth();
return (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user">{auth.user ? auth.user.email : 'no-user'}</div>
<div data-testid="verified">{auth.user?.isVerified ? 'verified' : 'not-verified'}</div>
<div data-testid="modal-open">{auth.showAuthModal ? 'open' : 'closed'}</div>
<div data-testid="modal-mode">{auth.authModalMode}</div>
<button onClick={() => auth.login('test@example.com', 'password123')}>Login</button>
<button onClick={() => auth.register({ email: 'new@example.com', username: 'newuser', password: 'password123' })}>Register</button>
<button onClick={() => auth.googleLogin('valid-google-code')}>Google Login</button>
<button onClick={() => auth.logout()}>Logout</button>
<button onClick={() => auth.openAuthModal('login')}>Open Login Modal</button>
<button onClick={() => auth.openAuthModal('signup')}>Open Signup Modal</button>
<button onClick={() => auth.closeAuthModal()}>Close Modal</button>
<button onClick={() => auth.checkAuth()}>Check Auth</button>
</div>
);
};
// Wrapper component for testing
const renderWithAuth = (ui: React.ReactElement = <TestComponent />) => {
return render(
<AuthProvider>
{ui}
</AuthProvider>
);
};
describe('AuthContext', () => {
beforeEach(() => {
jest.clearAllMocks();
// Default: user is authenticated
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: true, user: mockUser },
});
mockFetchCSRFToken.mockResolvedValue('test-csrf-token');
});
describe('useAuth hook', () => {
it('throws error when used outside AuthProvider', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TestComponent />)).toThrow('useAuth must be used within an AuthProvider');
consoleError.mockRestore();
});
});
describe('Initial State', () => {
it('starts with loading state', () => {
renderWithAuth();
expect(screen.getByTestId('loading')).toHaveTextContent('loading');
});
it('checks authentication status on mount', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
expect(mockAuthAPI.getStatus).toHaveBeenCalled();
});
it('sets user to null when not authenticated', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
it('handles network errors gracefully', async () => {
mockAuthAPI.getStatus.mockRejectedValue(new Error('Network error'));
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
});
describe('Login', () => {
it('logs in successfully with valid credentials', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.login.mockResolvedValue({
data: { user: mockUser },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
await act(async () => {
fireEvent.click(screen.getByText('Login'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
expect(mockAuthAPI.login).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('keeps user as null when login fails', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.login.mockRejectedValue(new Error('Invalid credentials'));
// Create a test component that captures login errors
const LoginErrorTestComponent: React.FC = () => {
const auth = useAuth();
const [error, setError] = React.useState<string | null>(null);
const handleLogin = async () => {
try {
await auth.login('test@example.com', 'wrongpassword');
} catch (err: any) {
setError(err.message);
}
};
return (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user">{auth.user ? auth.user.email : 'no-user'}</div>
<div data-testid="error">{error || 'no-error'}</div>
<button onClick={handleLogin}>Login</button>
</div>
);
};
render(
<AuthProvider>
<LoginErrorTestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Login'));
});
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent('Invalid credentials');
});
// User should still be null after failed login
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
});
describe('Registration', () => {
it('registers a new user successfully', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.register.mockResolvedValue({
data: { user: { ...mockUser, email: 'new@example.com', isVerified: false } },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Register'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('new@example.com');
});
});
});
describe('Google Login', () => {
it('logs in with Google successfully', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.googleLogin.mockResolvedValue({
data: { user: mockUser },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Google Login'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
expect(mockAuthAPI.googleLogin).toHaveBeenCalledWith('valid-google-code');
});
});
describe('Logout', () => {
it('logs out successfully', async () => {
mockAuthAPI.logout.mockResolvedValue({ data: { message: 'Logged out' } });
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
await act(async () => {
fireEvent.click(screen.getByText('Logout'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
expect(mockResetCSRFToken).toHaveBeenCalled();
});
it('clears user state even if logout API fails', async () => {
mockAuthAPI.logout.mockRejectedValue(new Error('Server error'));
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
await act(async () => {
fireEvent.click(screen.getByText('Logout'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
});
});
describe('Auth Modal', () => {
it('opens login modal', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('closed');
await act(async () => {
fireEvent.click(screen.getByText('Open Login Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('open');
expect(screen.getByTestId('modal-mode')).toHaveTextContent('login');
});
it('opens signup modal', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Open Signup Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('open');
expect(screen.getByTestId('modal-mode')).toHaveTextContent('signup');
});
it('closes modal', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Open Login Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('open');
await act(async () => {
fireEvent.click(screen.getByText('Close Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('closed');
});
});
describe('updateUser', () => {
it('updates user state', async () => {
const TestComponentWithUpdate: React.FC = () => {
const auth = useAuth();
return (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user-email">{auth.user?.email || 'no-user'}</div>
<div data-testid="user-name">{auth.user?.firstName || 'no-name'}</div>
<button onClick={() => auth.updateUser({
...auth.user!,
firstName: 'Updated',
lastName: 'Name',
})}>
Update User
</button>
</div>
);
};
renderWithAuth(<TestComponentWithUpdate />);
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user-name')).toHaveTextContent('Test');
await act(async () => {
fireEvent.click(screen.getByText('Update User'));
});
expect(screen.getByTestId('user-name')).toHaveTextContent('Updated');
});
});
describe('checkAuth', () => {
it('refreshes authentication status', async () => {
let callCount = 0;
mockAuthAPI.getStatus.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ data: { authenticated: false, user: null } });
}
return Promise.resolve({ data: { authenticated: true, user: mockUser } });
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
await act(async () => {
fireEvent.click(screen.getByText('Check Auth'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
});
});
describe('OAuth Callback Handling', () => {
it('skips auth check on OAuth callback page', async () => {
// Mock being on the OAuth callback page
Object.defineProperty(window, 'location', {
value: {
...window.location,
pathname: '/auth/google/callback',
},
writable: true,
});
mockAuthAPI.getStatus.mockClear();
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
// Status should not be called on OAuth callback page
expect(mockAuthAPI.getStatus).not.toHaveBeenCalled();
// Reset location
Object.defineProperty(window, 'location', {
value: {
...window.location,
pathname: '/',
},
writable: true,
});
});
});
});

View File

@@ -0,0 +1,346 @@
import { renderHook } from '@testing-library/react';
import { useAddressAutocomplete, usStates } from '../../hooks/useAddressAutocomplete';
import { PlaceDetails } from '../../services/placesService';
describe('useAddressAutocomplete', () => {
describe('usStates', () => {
it('contains all 50 US states', () => {
expect(usStates).toHaveLength(50);
});
it('includes common states', () => {
expect(usStates).toContain('California');
expect(usStates).toContain('New York');
expect(usStates).toContain('Texas');
expect(usStates).toContain('Florida');
});
it('states are in alphabetical order', () => {
const sorted = [...usStates].sort();
expect(usStates).toEqual(sorted);
});
});
describe('parsePlace', () => {
const { result } = renderHook(() => useAddressAutocomplete());
it('parses a complete place correctly', () => {
const place: PlaceDetails = {
formattedAddress: '123 Main Street, Los Angeles, CA 90210, USA',
addressComponents: {
streetNumber: '123',
route: 'Main Street',
locality: 'Los Angeles',
administrativeAreaLevel1: 'CA',
administrativeAreaLevel1Long: 'California',
postalCode: '90210',
country: 'US',
},
geometry: {
latitude: 34.0522,
longitude: -118.2437,
},
placeId: 'test-place-id',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('123 Main Street');
expect(parsed!.city).toBe('Los Angeles');
expect(parsed!.state).toBe('California');
expect(parsed!.zipCode).toBe('90210');
expect(parsed!.country).toBe('US');
expect(parsed!.latitude).toBe(34.0522);
expect(parsed!.longitude).toBe(-118.2437);
});
it('converts state codes to full names', () => {
const place: PlaceDetails = {
formattedAddress: '456 Oak Ave, New York, NY 10001, USA',
addressComponents: {
streetNumber: '456',
route: 'Oak Ave',
locality: 'New York',
administrativeAreaLevel1: 'NY',
administrativeAreaLevel1Long: 'New York',
postalCode: '10001',
country: 'US',
},
geometry: {
latitude: 40.7128,
longitude: -74.006,
},
placeId: 'test-place-id-2',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.state).toBe('New York');
});
it('uses formatted address when street number and route are missing', () => {
const place: PlaceDetails = {
formattedAddress: 'Some Place, Austin, TX 78701, USA',
addressComponents: {
locality: 'Austin',
administrativeAreaLevel1: 'TX',
administrativeAreaLevel1Long: 'Texas',
postalCode: '78701',
country: 'US',
},
geometry: {
latitude: 30.2672,
longitude: -97.7431,
},
placeId: 'test-place-id-3',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('Some Place, Austin, TX 78701, USA');
});
it('handles missing postal code gracefully', () => {
const place: PlaceDetails = {
formattedAddress: '789 Pine St, Seattle, WA, USA',
addressComponents: {
streetNumber: '789',
route: 'Pine St',
locality: 'Seattle',
administrativeAreaLevel1: 'WA',
administrativeAreaLevel1Long: 'Washington',
country: 'US',
},
geometry: {
latitude: 47.6062,
longitude: -122.3321,
},
placeId: 'test-place-id-4',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.zipCode).toBe('');
expect(parsed!.state).toBe('Washington');
});
it('handles missing city gracefully', () => {
const place: PlaceDetails = {
formattedAddress: '100 Rural Road, CO 80000, USA',
addressComponents: {
streetNumber: '100',
route: 'Rural Road',
administrativeAreaLevel1: 'CO',
administrativeAreaLevel1Long: 'Colorado',
postalCode: '80000',
country: 'US',
},
geometry: {
latitude: 39.5501,
longitude: -105.7821,
},
placeId: 'test-place-id-5',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.city).toBe('');
});
it('sets state to empty string for unknown states', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const place: PlaceDetails = {
formattedAddress: '123 Street, City, XX 12345, USA',
addressComponents: {
streetNumber: '123',
route: 'Street',
locality: 'City',
administrativeAreaLevel1: 'XX',
administrativeAreaLevel1Long: 'Unknown State',
postalCode: '12345',
country: 'US',
},
geometry: {
latitude: 0,
longitude: 0,
},
placeId: 'test-place-id-6',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.state).toBe('');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('State not found in dropdown options')
);
consoleSpy.mockRestore();
});
it('handles DC (District of Columbia) correctly', () => {
const place: PlaceDetails = {
formattedAddress: '1600 Pennsylvania Ave, Washington, DC 20500, USA',
addressComponents: {
streetNumber: '1600',
route: 'Pennsylvania Ave',
locality: 'Washington',
administrativeAreaLevel1: 'DC',
administrativeAreaLevel1Long: 'District of Columbia',
postalCode: '20500',
country: 'US',
},
geometry: {
latitude: 38.8977,
longitude: -77.0365,
},
placeId: 'test-place-id-dc',
};
const parsed = result.current.parsePlace(place);
// DC is not in the 50 states list, so state should be empty
expect(parsed).not.toBeNull();
expect(parsed!.city).toBe('Washington');
});
it('uses long state name from administrativeAreaLevel1Long when available', () => {
const place: PlaceDetails = {
formattedAddress: '500 Beach Blvd, Miami, FL 33101, USA',
addressComponents: {
streetNumber: '500',
route: 'Beach Blvd',
locality: 'Miami',
administrativeAreaLevel1: 'FL',
administrativeAreaLevel1Long: 'Florida',
postalCode: '33101',
country: 'US',
},
geometry: {
latitude: 25.7617,
longitude: -80.1918,
},
placeId: 'test-place-id-fl',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.state).toBe('Florida');
});
it('returns null and logs error when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
// Pass an object that will cause an error when accessing nested properties
const invalidPlace = {
formattedAddress: 'Test',
addressComponents: null,
geometry: { latitude: 0, longitude: 0 },
placeId: 'test',
} as unknown as PlaceDetails;
const parsed = result.current.parsePlace(invalidPlace);
expect(parsed).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Error parsing place details:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('handles only street number without route', () => {
const place: PlaceDetails = {
formattedAddress: '42, Chicago, IL 60601, USA',
addressComponents: {
streetNumber: '42',
locality: 'Chicago',
administrativeAreaLevel1: 'IL',
administrativeAreaLevel1Long: 'Illinois',
postalCode: '60601',
country: 'US',
},
geometry: {
latitude: 41.8781,
longitude: -87.6298,
},
placeId: 'test-place-id-number-only',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('42');
});
it('handles only route without street number', () => {
const place: PlaceDetails = {
formattedAddress: 'Main Street, Boston, MA 02101, USA',
addressComponents: {
route: 'Main Street',
locality: 'Boston',
administrativeAreaLevel1: 'MA',
administrativeAreaLevel1Long: 'Massachusetts',
postalCode: '02101',
country: 'US',
},
geometry: {
latitude: 42.3601,
longitude: -71.0589,
},
placeId: 'test-place-id-route-only',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('Main Street');
});
it('defaults country to US when not provided', () => {
const place: PlaceDetails = {
formattedAddress: '999 Test St, Denver, CO 80202',
addressComponents: {
streetNumber: '999',
route: 'Test St',
locality: 'Denver',
administrativeAreaLevel1: 'CO',
administrativeAreaLevel1Long: 'Colorado',
postalCode: '80202',
},
geometry: {
latitude: 39.7392,
longitude: -104.9903,
},
placeId: 'test-place-id-no-country',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.country).toBe('US');
});
});
describe('hook stability', () => {
it('returns stable parsePlace function', () => {
const { result, rerender } = renderHook(() => useAddressAutocomplete());
const firstParsePlace = result.current.parsePlace;
rerender();
const secondParsePlace = result.current.parsePlace;
expect(firstParsePlace).toBe(secondParsePlace);
});
});
});

View File

@@ -0,0 +1,211 @@
/**
* API Service Tests
*
* Tests the API service module structure and exported functions.
* API interceptor behavior is tested in integration with AuthContext.
*/
import {
authAPI,
userAPI,
itemAPI,
rentalAPI,
messageAPI,
mapsAPI,
stripeAPI,
addressAPI,
conditionCheckAPI,
forumAPI,
feedbackAPI,
fetchCSRFToken,
resetCSRFToken,
getMessageImageUrl,
getForumImageUrl,
} from '../../services/api';
import api from '../../services/api';
describe('API Service', () => {
describe('Module Exports', () => {
it('exports authAPI with correct methods', () => {
expect(authAPI).toBeDefined();
expect(typeof authAPI.login).toBe('function');
expect(typeof authAPI.register).toBe('function');
expect(typeof authAPI.logout).toBe('function');
expect(typeof authAPI.googleLogin).toBe('function');
expect(typeof authAPI.getStatus).toBe('function');
expect(typeof authAPI.verifyEmail).toBe('function');
expect(typeof authAPI.forgotPassword).toBe('function');
expect(typeof authAPI.resetPassword).toBe('function');
});
it('exports userAPI with correct methods', () => {
expect(userAPI).toBeDefined();
expect(typeof userAPI.getProfile).toBe('function');
expect(typeof userAPI.updateProfile).toBe('function');
expect(typeof userAPI.uploadProfileImage).toBe('function');
});
it('exports itemAPI with correct methods', () => {
expect(itemAPI).toBeDefined();
expect(typeof itemAPI.getItems).toBe('function');
expect(typeof itemAPI.getItem).toBe('function');
expect(typeof itemAPI.createItem).toBe('function');
expect(typeof itemAPI.updateItem).toBe('function');
expect(typeof itemAPI.deleteItem).toBe('function');
});
it('exports rentalAPI with correct methods', () => {
expect(rentalAPI).toBeDefined();
expect(typeof rentalAPI.createRental).toBe('function');
expect(typeof rentalAPI.getRentals).toBe('function');
expect(typeof rentalAPI.getListings).toBe('function');
expect(typeof rentalAPI.updateRentalStatus).toBe('function');
expect(typeof rentalAPI.cancelRental).toBe('function');
});
it('exports messageAPI with correct methods', () => {
expect(messageAPI).toBeDefined();
expect(typeof messageAPI.getMessages).toBe('function');
expect(typeof messageAPI.getConversations).toBe('function');
expect(typeof messageAPI.sendMessage).toBe('function');
expect(typeof messageAPI.getUnreadCount).toBe('function');
});
it('exports mapsAPI with correct methods', () => {
expect(mapsAPI).toBeDefined();
expect(typeof mapsAPI.placesAutocomplete).toBe('function');
expect(typeof mapsAPI.placeDetails).toBe('function');
expect(typeof mapsAPI.geocode).toBe('function');
});
it('exports stripeAPI with correct methods', () => {
expect(stripeAPI).toBeDefined();
expect(typeof stripeAPI.getCheckoutSession).toBe('function');
expect(typeof stripeAPI.createConnectedAccount).toBe('function');
expect(typeof stripeAPI.createAccountLink).toBe('function');
expect(typeof stripeAPI.getAccountStatus).toBe('function');
});
it('exports CSRF token management functions', () => {
expect(typeof fetchCSRFToken).toBe('function');
expect(typeof resetCSRFToken).toBe('function');
});
it('exports helper functions for image URLs', () => {
expect(typeof getMessageImageUrl).toBe('function');
expect(typeof getForumImageUrl).toBe('function');
});
});
describe('Helper Functions', () => {
it('getMessageImageUrl constructs correct URL', () => {
const url = getMessageImageUrl('test-image.jpg');
expect(url).toContain('/messages/images/test-image.jpg');
});
it('getForumImageUrl constructs correct URL', () => {
const url = getForumImageUrl('forum-image.jpg');
expect(url).toContain('/uploads/forum/forum-image.jpg');
});
});
describe('CSRF Token Management', () => {
it('resetCSRFToken clears the token', () => {
// Should not throw
expect(() => resetCSRFToken()).not.toThrow();
});
});
describe('API Configuration', () => {
it('creates axios instance with correct base URL', () => {
expect(api).toBeDefined();
expect(api.defaults).toBeDefined();
});
});
});
describe('API Namespaces', () => {
describe('authAPI', () => {
it('has all authentication methods', () => {
const expectedMethods = [
'register',
'login',
'googleLogin',
'logout',
'refresh',
'getCSRFToken',
'getStatus',
'verifyEmail',
'resendVerification',
'forgotPassword',
'verifyResetToken',
'resetPassword',
];
expectedMethods.forEach((method) => {
expect((authAPI as any)[method]).toBeDefined();
expect(typeof (authAPI as any)[method]).toBe('function');
});
});
});
describe('addressAPI', () => {
it('has all address management methods', () => {
const expectedMethods = [
'getAddresses',
'createAddress',
'updateAddress',
'deleteAddress',
];
expectedMethods.forEach((method) => {
expect((addressAPI as any)[method]).toBeDefined();
expect(typeof (addressAPI as any)[method]).toBe('function');
});
});
});
describe('conditionCheckAPI', () => {
it('has all condition check methods', () => {
const expectedMethods = [
'submitConditionCheck',
'getConditionChecks',
'getConditionCheckTimeline',
'getAvailableChecks',
];
expectedMethods.forEach((method) => {
expect((conditionCheckAPI as any)[method]).toBeDefined();
expect(typeof (conditionCheckAPI as any)[method]).toBe('function');
});
});
});
describe('forumAPI', () => {
it('has all forum methods', () => {
const expectedMethods = [
'getPosts',
'getPost',
'createPost',
'updatePost',
'deletePost',
'createComment',
'updateComment',
'deleteComment',
'getTags',
];
expectedMethods.forEach((method) => {
expect((forumAPI as any)[method]).toBeDefined();
expect(typeof (forumAPI as any)[method]).toBe('function');
});
});
});
describe('feedbackAPI', () => {
it('has feedback submission method', () => {
expect(feedbackAPI.submitFeedback).toBeDefined();
expect(typeof feedbackAPI.submitFeedback).toBe('function');
});
});
});

View File

@@ -437,9 +437,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* Header */}
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
<div className="d-flex align-items-center">
{recipient.profileImage ? (
{recipient.imageFilename ? (
<img
src={recipient.profileImage}
src={recipient.imageFilename}
alt={`${recipient.firstName} ${recipient.lastName}`}
className="rounded-circle me-2"
style={{ width: "35px", height: "35px", objectFit: "cover" }}
@@ -525,10 +525,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
wordBreak: "break-word",
}}
>
{message.imagePath && (
{message.imageFilename && (
<div className="mb-2">
<img
src={getMessageImageUrl(message.imagePath)}
src={getMessageImageUrl(message.imageFilename)}
alt="Shared image"
style={{
width: "100%",
@@ -539,7 +539,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}}
onClick={() =>
window.open(
getMessageImageUrl(message.imagePath!),
getMessageImageUrl(message.imageFilename!),
"_blank"
)
}

View File

@@ -212,9 +212,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
{comment.content}
</p>
{comment.images && comment.images.length > 0 && (
{comment.imageFilenames && comment.imageFilenames.length > 0 && (
<div className="row g-2 mb-2">
{comment.images.map((image, index) => (
{comment.imageFilenames.map((image, index) => (
<div key={index} className="col-4 col-md-3">
<img
src={getForumImageUrl(image)}

View File

@@ -47,9 +47,9 @@ const ItemCard: React.FC<ItemCardProps> = ({
return (
<Link to={`/items/${item.id}`} className="text-decoration-none">
<div className="card h-100" style={{ cursor: 'pointer' }}>
{item.images && item.images[0] ? (
{item.imageFilenames && item.imageFilenames[0] ? (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{

View File

@@ -29,9 +29,9 @@ const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails })
return (
<div style={{ width: 'min(280px, 90vw)', maxWidth: '280px' }}>
<div className="card border-0">
{item.images && item.images[0] ? (
{item.imageFilenames && item.imageFilenames[0] ? (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{

View File

@@ -85,9 +85,9 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
style={{ cursor: "pointer" }}
>
{rental.renter?.profileImage ? (
{rental.renter?.imageFilename ? (
<img
src={rental.renter.profileImage}
src={rental.renter.imageFilename}
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
className="rounded-circle"
style={{

View File

@@ -102,9 +102,9 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
{rental.owner && rental.item && (
<div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3">
{rental.owner.profileImage ? (
{rental.owner.imageFilename ? (
<img
src={rental.owner.profileImage}
src={rental.owner.imageFilename}
alt={`${rental.owner.firstName} ${rental.owner.lastName}`}
className="rounded-circle"
style={{

View File

@@ -102,9 +102,9 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
{rental.renter && rental.item && (
<div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3">
{rental.renter.profileImage ? (
{rental.renter.imageFilename ? (
<img
src={rental.renter.profileImage}
src={rental.renter.imageFilename}
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
className="rounded-circle"
style={{

View File

@@ -0,0 +1,72 @@
/**
* Mock data for tests
*/
// Mock user data
export const mockUser = {
id: '1',
username: 'testuser',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
isVerified: true,
role: 'user' as const,
};
export const mockUnverifiedUser = {
...mockUser,
id: '2',
email: 'unverified@example.com',
isVerified: false,
};
// Mock item data
export const mockItem = {
id: '1',
name: 'Test Item',
description: 'A test item for rental',
pricePerDay: 25,
replacementCost: 500,
condition: 'excellent' as const,
isAvailable: true,
images: ['image1.jpg'],
ownerId: '2',
city: 'Test City',
state: 'California',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Mock rental data
export const mockRental = {
id: '1',
itemId: '1',
renterId: '1',
ownerId: '2',
startDateTime: new Date().toISOString(),
endDateTime: new Date(Date.now() + 86400000).toISOString(),
totalAmount: 25,
status: 'pending' as const,
paymentStatus: 'pending' as const,
deliveryMethod: 'pickup' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Mock API response helpers
export const createMockResponse = <T>(data: T, status = 200) => ({
data,
status,
statusText: 'OK',
headers: {},
config: {},
});
export const createMockError = (message: string, status: number, code?: string) => {
const error = new Error(message) as any;
error.response = {
status,
data: { message, code },
};
return error;
};

View File

@@ -0,0 +1,43 @@
/**
* Mock server using Jest mocks instead of MSW.
* This provides a simpler setup that works with all Node versions.
*/
import { mockUser, mockUnverifiedUser, mockItem, mockRental } from './handlers';
// Re-export mock data
export { mockUser, mockUnverifiedUser, mockItem, mockRental };
// Mock server interface for compatibility with setup
export const server = {
listen: jest.fn(),
resetHandlers: jest.fn(),
close: jest.fn(),
use: jest.fn(),
};
// Setup axios mock
jest.mock('axios', () => {
const mockAxiosInstance = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
interceptors: {
request: { use: jest.fn(), eject: jest.fn() },
response: { use: jest.fn(), eject: jest.fn() },
},
defaults: {
headers: {
common: {},
},
},
};
return {
create: jest.fn(() => mockAxiosInstance),
default: mockAxiosInstance,
...mockAxiosInstance,
};
});

View File

@@ -162,8 +162,8 @@ const EditItem: React.FC = () => {
});
// Set existing images as previews
if (item.images && item.images.length > 0) {
setImagePreviews(item.images);
if (item.imageFilenames && item.imageFilenames.length > 0) {
setImagePreviews(item.imageFilenames);
}
// Determine which pricing unit to select based on existing data

View File

@@ -343,9 +343,9 @@ const ForumPostDetail: React.FC = () => {
{post.content}
</div>
{post.images && post.images.length > 0 && (
{post.imageFilenames && post.imageFilenames.length > 0 && (
<div className="row g-2 mb-3">
{post.images.map((image, index) => (
{post.imageFilenames.map((image, index) => (
<div key={index} className="col-6 col-md-4">
<img
src={getForumImageUrl(image)}

View File

@@ -414,10 +414,10 @@ const ItemDetail: React.FC = () => {
<div className="row">
<div className="col-md-8">
{/* Images */}
{item.images.length > 0 ? (
{item.imageFilenames.length > 0 ? (
<div className="mb-4">
<img
src={item.images[selectedImage]}
src={item.imageFilenames[selectedImage]}
alt={item.name}
className="img-fluid rounded mb-3"
style={{
@@ -426,9 +426,9 @@ const ItemDetail: React.FC = () => {
objectFit: "cover",
}}
/>
{item.images.length > 1 && (
{item.imageFilenames.length > 1 && (
<div className="d-flex gap-2 overflow-auto justify-content-center">
{item.images.map((image, index) => (
{item.imageFilenames.map((image, index) => (
<img
key={index}
src={image}
@@ -478,9 +478,9 @@ const ItemDetail: React.FC = () => {
onClick={() => navigate(`/users/${item.ownerId}`)}
style={{ cursor: "pointer" }}
>
{item.owner.profileImage ? (
{item.owner.imageFilename ? (
<img
src={item.owner.profileImage}
src={item.owner.imageFilename}
alt={`${item.owner.firstName} ${item.owner.lastName}`}
className="rounded-circle me-2"
style={{

View File

@@ -230,9 +230,9 @@ const Messages: React.FC = () => {
<div className="d-flex w-100 justify-content-between align-items-start">
<div className="d-flex align-items-center flex-grow-1">
{/* Profile Picture */}
{conversation.partner.profileImage ? (
{conversation.partner.imageFilename ? (
<img
src={conversation.partner.profileImage}
src={conversation.partner.imageFilename}
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
className="rounded-circle me-3"
style={{

View File

@@ -306,9 +306,9 @@ const Owning: React.FC = () => {
{allOwnerRentals.map((rental) => (
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
<div className="card h-100">
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "cover" }}
@@ -527,9 +527,9 @@ const Owning: React.FC = () => {
navigate(`/items/${item.id}`);
}}
>
{item.images && item.images[0] && (
{item.imageFilenames && item.imageFilenames[0] && (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{ height: "200px", objectFit: "cover" }}

View File

@@ -39,7 +39,7 @@ const Profile: React.FC = () => {
state: string;
zipCode: string;
country: string;
profileImage: string;
imageFilename: string;
itemRequestNotificationRadius: number | null;
}>({
firstName: "",
@@ -52,7 +52,7 @@ const Profile: React.FC = () => {
state: "",
zipCode: "",
country: "",
profileImage: "",
imageFilename: "",
itemRequestNotificationRadius: 10,
});
const [imageFile, setImageFile] = useState<File | null>(null);
@@ -156,12 +156,12 @@ const Profile: React.FC = () => {
state: response.data.state || "",
zipCode: response.data.zipCode || "",
country: response.data.country || "",
profileImage: response.data.profileImage || "",
imageFilename: response.data.imageFilename || "",
itemRequestNotificationRadius:
response.data.itemRequestNotificationRadius || 10,
});
if (response.data.profileImage) {
setImagePreview(getImageUrl(response.data.profileImage));
if (response.data.imageFilename) {
setImagePreview(getImageUrl(response.data.imageFilename));
}
} catch (err: any) {
setError(err.response?.data?.message || "Failed to fetch profile");
@@ -304,14 +304,14 @@ const Profile: React.FC = () => {
// Upload image immediately
try {
const formData = new FormData();
formData.append("profileImage", file);
formData.append("imageFilename", file);
const response = await userAPI.uploadProfileImage(formData);
// Update the profileImage in formData with the new filename
// Update the imageFilename in formData with the new filename
setFormData((prev) => ({
...prev,
profileImage: response.data.filename,
imageFilename: response.data.filename,
}));
// Update preview to use the uploaded image URL
@@ -322,8 +322,8 @@ const Profile: React.FC = () => {
// Reset on error
setImageFile(null);
setImagePreview(
profileData?.profileImage
? getImageUrl(profileData.profileImage)
profileData?.imageFilename
? getImageUrl(profileData.imageFilename)
: null
);
}
@@ -336,8 +336,8 @@ const Profile: React.FC = () => {
setSuccess(null);
try {
// Don't send profileImage in the update data as it's handled separately
const { profileImage, ...updateData } = formData;
// Don't send imageFilename in the update data as it's handled separately
const { imageFilename, ...updateData } = formData;
const response = await userAPI.updateProfile(updateData);
setProfileData(response.data);
@@ -379,12 +379,12 @@ const Profile: React.FC = () => {
state: profileData.state || "",
zipCode: profileData.zipCode || "",
country: profileData.country || "",
profileImage: profileData.profileImage || "",
imageFilename: profileData.imageFilename || "",
itemRequestNotificationRadius:
profileData.itemRequestNotificationRadius || 10,
});
setImagePreview(
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
profileData.imageFilename ? getImageUrl(profileData.imageFilename) : null
);
}
};
@@ -774,7 +774,7 @@ const Profile: React.FC = () => {
)}
{editing && (
<label
htmlFor="profileImageOverview"
htmlFor="imageFilenameOverview"
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
style={{
width: "35px",
@@ -785,7 +785,7 @@ const Profile: React.FC = () => {
<i className="bi bi-camera-fill"></i>
<input
type="file"
id="profileImageOverview"
id="imageFilenameOverview"
accept="image/*"
onChange={handleImageChange}
className="d-none"
@@ -1222,9 +1222,9 @@ const Profile: React.FC = () => {
className="col-md-6 col-lg-4 mb-4"
>
<div className="card h-100">
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{
@@ -1359,9 +1359,9 @@ const Profile: React.FC = () => {
className="col-md-6 col-lg-4 mb-4"
>
<div className="card h-100">
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{

View File

@@ -71,9 +71,9 @@ const PublicProfile: React.FC = () => {
<div className="card">
<div className="card-body">
<div className="text-center mb-4">
{user.profileImage ? (
{user.imageFilename ? (
<img
src={user.profileImage}
src={user.imageFilename}
alt={`${user.firstName} ${user.lastName}`}
className="rounded-circle mb-3"
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
@@ -111,9 +111,9 @@ const PublicProfile: React.FC = () => {
onClick={() => navigate(`/items/${item.id}`)}
style={{ cursor: 'pointer' }}
>
{item.images.length > 0 ? (
{item.imageFilenames.length > 0 ? (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{ height: '200px', objectFit: 'cover' }}

View File

@@ -341,9 +341,9 @@ const RentItem: React.FC = () => {
<div className="col-md-4">
<div className="card">
<div className="card-body">
{item.images && item.images[0] && (
{item.imageFilenames && item.imageFilenames[0] && (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
alt={item.name}
className="img-fluid rounded mb-3"
style={{

View File

@@ -230,9 +230,9 @@ const Renting: React.FC = () => {
className="card h-100"
style={{ cursor: rental.item ? "pointer" : "default" }}
>
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "cover" }}

View File

@@ -3,3 +3,53 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Mock window.location for tests that use navigation
const mockLocation = {
...window.location,
href: 'http://localhost:3000',
pathname: '/',
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
};
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Suppress console errors during tests (optional, comment out for debugging)
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.error = (...args: any[]) => {
// Filter out known React warnings during tests
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning: ReactDOM.render is no longer supported') ||
args[0].includes('Warning: An update to') ||
args[0].includes('act(...)'))
) {
return;
}
originalConsoleError.call(console, ...args);
};
console.warn = (...args: any[]) => {
// Filter out known warnings
if (
typeof args[0] === 'string' &&
args[0].includes('componentWillReceiveProps')
) {
return;
}
originalConsoleWarn.call(console, ...args);
};
});
afterAll(() => {
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
});

View File

@@ -0,0 +1,26 @@
// Polyfills for MSW 2.x - must be loaded before MSW
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// Polyfill for fetch, Request, Response, Headers
const { fetch, Headers, Request, Response } = require('cross-fetch');
global.fetch = fetch;
global.Headers = Headers;
global.Request = Request;
global.Response = Response;
// BroadcastChannel polyfill for MSW
class BroadcastChannel {
constructor(name) {
this.name = name;
}
postMessage() {}
close() {}
addEventListener() {}
removeEventListener() {}
}
global.BroadcastChannel = BroadcastChannel;

View File

@@ -27,7 +27,7 @@ export interface User {
state?: string;
zipCode?: string;
country?: string;
profileImage?: string;
imageFilename?: string;
isVerified: boolean;
role?: "user" | "admin";
stripeConnectedAccountId?: string;
@@ -41,7 +41,7 @@ export interface Message {
receiverId: string;
content: string;
isRead: boolean;
imagePath?: string;
imageFilename?: string;
sender?: User;
receiver?: User;
createdAt: string;
@@ -84,7 +84,7 @@ export interface Item {
country?: string;
latitude?: number;
longitude?: number;
images: string[];
imageFilenames: string[];
condition: "excellent" | "good" | "fair" | "poor";
isAvailable: boolean;
rules?: string;
@@ -187,7 +187,7 @@ export interface ConditionCheck {
| "rental_start_renter"
| "rental_end_renter"
| "post_rental_owner";
photos: string[];
imageFilenames: string[];
notes?: string;
submittedBy: string;
submittedAt: string;
@@ -212,7 +212,7 @@ export interface DamageAssessment {
needsReplacement: boolean;
replacementCost?: number;
proofOfOwnership?: string[];
photos?: string[];
imageFilenames?: string[];
assessedAt: string;
assessedBy: string;
feeCalculation: {
@@ -265,7 +265,7 @@ export interface ForumPost {
commentCount: number;
isPinned: boolean;
acceptedAnswerId?: string;
images?: string[];
imageFilenames?: string[];
isDeleted?: boolean;
deletedBy?: string;
deletedAt?: string;
@@ -287,7 +287,7 @@ export interface ForumComment {
content: string;
parentCommentId?: string;
isDeleted: boolean;
images?: string[];
imageFilenames?: string[];
deletedBy?: string;
deletedAt?: string;
author?: User;