imageFilenames and imageFilename, backend integration tests, frontend tests, removed username references
This commit is contained in:
@@ -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: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -24,8 +24,8 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
|
|||||||
),
|
),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
photos: {
|
imageFilenames: {
|
||||||
type: DataTypes.ARRAY(DataTypes.STRING),
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
notes: {
|
notes: {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const ForumComment = sequelize.define('ForumComment', {
|
|||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
images: {
|
imageFilenames: {
|
||||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const ForumPost = sequelize.define('ForumPost', {
|
|||||||
key: 'id'
|
key: 'id'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
images: {
|
imageFilenames: {
|
||||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ const Item = sequelize.define("Item", {
|
|||||||
longitude: {
|
longitude: {
|
||||||
type: DataTypes.DECIMAL(11, 8),
|
type: DataTypes.DECIMAL(11, 8),
|
||||||
},
|
},
|
||||||
images: {
|
imageFilenames: {
|
||||||
type: DataTypes.ARRAY(DataTypes.STRING),
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
isAvailable: {
|
isAvailable: {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const Message = sequelize.define('Message', {
|
|||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
imagePath: {
|
imageFilename: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.TEXT,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ const User = sequelize.define(
|
|||||||
country: {
|
country: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
},
|
},
|
||||||
profileImage: {
|
imageFilename: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
isVerified: {
|
isVerified: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|||||||
@@ -101,12 +101,14 @@ router.post(
|
|||||||
phone,
|
phone,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Link alpha invitation to user
|
// Link alpha invitation to user (only if alpha testing is enabled)
|
||||||
await alphaInvitation.update({
|
if (alphaInvitation) {
|
||||||
usedBy: user.id,
|
await alphaInvitation.update({
|
||||||
usedAt: new Date(),
|
usedBy: user.id,
|
||||||
status: "active",
|
usedAt: new Date(),
|
||||||
});
|
status: "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generate verification token and send email
|
// Generate verification token and send email
|
||||||
await user.generateVerificationToken();
|
await user.generateVerificationToken();
|
||||||
@@ -367,7 +369,7 @@ router.post(
|
|||||||
lastName,
|
lastName,
|
||||||
authProvider: "google",
|
authProvider: "google",
|
||||||
providerId: googleId,
|
providerId: googleId,
|
||||||
profileImage: picture,
|
imageFilename: picture,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
verifiedAt: new Date(),
|
verifiedAt: new Date(),
|
||||||
});
|
});
|
||||||
@@ -434,7 +436,7 @@ router.post(
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
profileImage: user.profileImage,
|
imageFilename: user.imageFilename,
|
||||||
isVerified: user.isVerified,
|
isVerified: user.isVerified,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const upload = multer({
|
|||||||
router.post(
|
router.post(
|
||||||
"/:rentalId",
|
"/:rentalId",
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
upload.array("photos"),
|
upload.array("imageFilenames"),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rentalId } = req.params;
|
const { rentalId } = req.params;
|
||||||
@@ -35,13 +35,13 @@ router.post(
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Get uploaded file paths
|
// 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(
|
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
userId,
|
userId,
|
||||||
photos,
|
imageFilenames,
|
||||||
notes
|
notes
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ router.post(
|
|||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
userId,
|
userId,
|
||||||
photoCount: photos.length,
|
photoCount: imageFilenames.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const buildCommentTree = (comments, isAdmin = false) => {
|
|||||||
// Sanitize deleted comments for non-admin users
|
// Sanitize deleted comments for non-admin users
|
||||||
if (commentJson.isDeleted && !isAdmin) {
|
if (commentJson.isDeleted && !isAdmin) {
|
||||||
commentJson.content = '';
|
commentJson.content = '';
|
||||||
commentJson.images = [];
|
commentJson.imageFilenames = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
commentMap[comment.id] = { ...commentJson, replies: [] };
|
commentMap[comment.id] = { ...commentJson, replies: [] };
|
||||||
@@ -252,7 +252,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract image filenames if uploaded
|
// 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
|
// Initialize location fields
|
||||||
let latitude = null;
|
let latitude = null;
|
||||||
@@ -313,7 +313,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res,
|
|||||||
content,
|
content,
|
||||||
category,
|
category,
|
||||||
authorId: req.user.id,
|
authorId: req.user.id,
|
||||||
images,
|
imageFilenames,
|
||||||
zipCode: zipCode || null,
|
zipCode: zipCode || null,
|
||||||
latitude,
|
latitude,
|
||||||
longitude
|
longitude
|
||||||
@@ -936,14 +936,14 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract image filenames if uploaded
|
// 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({
|
const comment = await ForumComment.create({
|
||||||
postId: req.params.id,
|
postId: req.params.id,
|
||||||
authorId: req.user.id,
|
authorId: req.user.id,
|
||||||
content,
|
content,
|
||||||
parentCommentId: parentCommentId || null,
|
parentCommentId: parentCommentId || null,
|
||||||
images
|
imageFilenames
|
||||||
});
|
});
|
||||||
|
|
||||||
// Increment comment count and update post's updatedAt to reflect activity
|
// Increment comment count and update post's updatedAt to reflect activity
|
||||||
@@ -955,7 +955,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'author',
|
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,
|
model: User,
|
||||||
as: 'author',
|
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,
|
model: User,
|
||||||
as: 'author',
|
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,
|
model: User,
|
||||||
as: 'author',
|
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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const admin = await User.findByPk(req.user.id, {
|
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)
|
// Get all unique participants (author + commenters)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ router.get('/', authenticateToken, async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'sender',
|
as: 'sender',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -61,12 +61,12 @@ router.get('/conversations', authenticateToken, async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'sender',
|
as: 'sender',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'receiver',
|
as: 'receiver',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -147,7 +147,7 @@ router.get('/sent', authenticateToken, async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'receiver',
|
as: 'receiver',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -186,12 +186,12 @@ router.get('/:id', authenticateToken, async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'sender',
|
as: 'sender',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'receiver',
|
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
|
// 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({
|
const message = await Message.create({
|
||||||
senderId: req.user.id,
|
senderId: req.user.id,
|
||||||
receiverId,
|
receiverId,
|
||||||
content,
|
content,
|
||||||
imagePath
|
imageFilename
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageWithSender = await Message.findByPk(message.id, {
|
const messageWithSender = await Message.findByPk(message.id, {
|
||||||
include: [{
|
include: [{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'sender',
|
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
|
// Verify user is sender or receiver of a message with this image
|
||||||
const message = await Message.findOne({
|
const message = await Message.findOne({
|
||||||
where: {
|
where: {
|
||||||
imagePath: filename,
|
imageFilename: filename,
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ senderId: req.user.id },
|
{ senderId: req.user.id },
|
||||||
{ receiverId: req.user.id }
|
{ receiverId: req.user.id }
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ router.post('/profile/image', authenticateToken, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Delete old profile image if exists
|
// Delete old profile image if exists
|
||||||
const user = await User.findByPk(req.user.id);
|
const user = await User.findByPk(req.user.id);
|
||||||
if (user.profileImage) {
|
if (user.imageFilename) {
|
||||||
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage);
|
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.imageFilename);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(oldImagePath);
|
await fs.unlink(oldImagePath);
|
||||||
} catch (unlinkErr) {
|
} catch (unlinkErr) {
|
||||||
@@ -227,7 +227,7 @@ router.post('/profile/image', authenticateToken, (req, res) => {
|
|||||||
|
|
||||||
// Update user with new image filename
|
// Update user with new image filename
|
||||||
await user.update({
|
await user.update({
|
||||||
profileImage: req.file.filename
|
imageFilename: req.file.filename
|
||||||
});
|
});
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class ConditionCheckService {
|
|||||||
* @param {string} rentalId - Rental ID
|
* @param {string} rentalId - Rental ID
|
||||||
* @param {string} checkType - Type of check
|
* @param {string} checkType - Type of check
|
||||||
* @param {string} userId - User submitting the 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
|
* @param {string} notes - Optional notes
|
||||||
* @returns {Object} - Created condition check
|
* @returns {Object} - Created condition check
|
||||||
*/
|
*/
|
||||||
@@ -124,7 +124,7 @@ class ConditionCheckService {
|
|||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
userId,
|
userId,
|
||||||
photos = [],
|
imageFilenames = [],
|
||||||
notes = null
|
notes = null
|
||||||
) {
|
) {
|
||||||
// Validate the check
|
// Validate the check
|
||||||
@@ -139,7 +139,7 @@ class ConditionCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate photos (basic validation)
|
// Validate photos (basic validation)
|
||||||
if (photos.length > 20) {
|
if (imageFilenames.length > 20) {
|
||||||
throw new Error("Maximum 20 photos allowed per condition check");
|
throw new Error("Maximum 20 photos allowed per condition check");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ class ConditionCheckService {
|
|||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
photos,
|
imageFilenames,
|
||||||
notes,
|
notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ class ConditionCheckService {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "submittedByUser",
|
as: "submittedByUser",
|
||||||
attributes: ["id", "username", "firstName", "lastName"],
|
attributes: ["id", "firstName", "lastName"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [["submittedAt", "ASC"]],
|
order: [["submittedAt", "ASC"]],
|
||||||
@@ -192,7 +192,7 @@ class ConditionCheckService {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "submittedByUser",
|
as: "submittedByUser",
|
||||||
attributes: ["id", "username", "firstName", "lastName"],
|
attributes: ["id", "firstName", "lastName"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -216,7 +216,7 @@ class ConditionCheckService {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
submittedAt: existingCheck.submittedAt,
|
submittedAt: existingCheck.submittedAt,
|
||||||
submittedBy: existingCheck.submittedBy,
|
submittedBy: existingCheck.submittedBy,
|
||||||
photoCount: existingCheck.photos.length,
|
photoCount: existingCheck.imageFilenames.length,
|
||||||
hasNotes: !!existingCheck.notes,
|
hasNotes: !!existingCheck.notes,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
621
backend/tests/integration/auth.integration.test.js
Normal file
621
backend/tests/integration/auth.integration.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
585
backend/tests/integration/rental.integration.test.js
Normal file
585
backend/tests/integration/rental.integration.test.js
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
/**
|
||||||
|
* Rental Integration Tests
|
||||||
|
*
|
||||||
|
* These tests use a real database connection to verify the complete
|
||||||
|
* rental lifecycle including creation, approval, completion, and
|
||||||
|
* cancellation flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { sequelize, User, Item, Rental } = require('../../models');
|
||||||
|
const rentalRoutes = require('../../routes/rentals');
|
||||||
|
|
||||||
|
// Test app setup
|
||||||
|
const createTestApp = () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Add request ID middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.id = 'test-request-id';
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/rentals', rentalRoutes);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate auth token for user
|
||||||
|
const generateAuthToken = (user) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id: user.id, jwtVersion: user.jwtVersion || 0 },
|
||||||
|
process.env.JWT_ACCESS_SECRET,
|
||||||
|
{ expiresIn: '15m' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test data factories
|
||||||
|
const createTestUser = async (overrides = {}) => {
|
||||||
|
const defaultData = {
|
||||||
|
email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`,
|
||||||
|
password: 'TestPassword123!',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
isVerified: true,
|
||||||
|
authProvider: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
return User.create({ ...defaultData, ...overrides });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestItem = async (ownerId, overrides = {}) => {
|
||||||
|
const defaultData = {
|
||||||
|
name: 'Test Item',
|
||||||
|
description: 'A test item for rental',
|
||||||
|
pricePerDay: 25.00,
|
||||||
|
pricePerHour: 5.00,
|
||||||
|
replacementCost: 500.00,
|
||||||
|
condition: 'excellent',
|
||||||
|
isAvailable: true,
|
||||||
|
pickUpAvailable: true,
|
||||||
|
ownerId,
|
||||||
|
city: 'Test City',
|
||||||
|
state: 'California',
|
||||||
|
};
|
||||||
|
|
||||||
|
return Item.create({ ...defaultData, ...overrides });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRental = async (itemId, renterId, ownerId, overrides = {}) => {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const defaultData = {
|
||||||
|
itemId,
|
||||||
|
renterId,
|
||||||
|
ownerId,
|
||||||
|
startDateTime: tomorrow,
|
||||||
|
endDateTime: new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000),
|
||||||
|
// Use free rentals to avoid Stripe payment requirements in tests
|
||||||
|
totalAmount: 0,
|
||||||
|
platformFee: 0,
|
||||||
|
payoutAmount: 0,
|
||||||
|
status: 'pending',
|
||||||
|
paymentStatus: 'pending',
|
||||||
|
deliveryMethod: 'pickup',
|
||||||
|
};
|
||||||
|
|
||||||
|
return Rental.create({ ...defaultData, ...overrides });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Rental Integration Tests', () => {
|
||||||
|
let app;
|
||||||
|
let owner;
|
||||||
|
let renter;
|
||||||
|
let item;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Set test environment variables
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
|
||||||
|
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||||
|
|
||||||
|
// Sync database
|
||||||
|
await sequelize.sync({ force: true });
|
||||||
|
|
||||||
|
app = createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await sequelize.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up in correct order (respecting foreign key constraints)
|
||||||
|
await Rental.destroy({ where: {}, truncate: true, cascade: true });
|
||||||
|
await Item.destroy({ where: {}, truncate: true, cascade: true });
|
||||||
|
await User.destroy({ where: {}, truncate: true, cascade: true });
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
owner = await createTestUser({
|
||||||
|
email: 'owner@example.com',
|
||||||
|
firstName: 'Item',
|
||||||
|
lastName: 'Owner',
|
||||||
|
stripeConnectedAccountId: 'acct_test_owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
renter = await createTestUser({
|
||||||
|
email: 'renter@example.com',
|
||||||
|
firstName: 'Item',
|
||||||
|
lastName: 'Renter',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test item
|
||||||
|
item = await createTestItem(owner.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /rentals/renting', () => {
|
||||||
|
it('should return rentals where user is the renter', async () => {
|
||||||
|
// Create a rental where renter is the renter
|
||||||
|
await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/renting')
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body.length).toBe(1);
|
||||||
|
expect(response.body[0].renterId).toBe(renter.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for user with no rentals', async () => {
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/renting')
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/renting')
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body.code).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /rentals/owning', () => {
|
||||||
|
it('should return rentals where user is the owner', async () => {
|
||||||
|
// Create a rental where owner is the item owner
|
||||||
|
await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/owning')
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body.length).toBe(1);
|
||||||
|
expect(response.body[0].ownerId).toBe(owner.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /rentals/:id/status', () => {
|
||||||
|
let rental;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rental = await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to confirm a pending rental', async () => {
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/rentals/${rental.id}/status`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ status: 'confirmed' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe('confirmed');
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow renter to update status (no owner-only restriction)', async () => {
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/rentals/${rental.id}/status`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ status: 'confirmed' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Note: API currently allows both owner and renter to update status
|
||||||
|
// Owner-specific logic (payment processing) only runs for owner
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle confirming already confirmed rental (idempotent)', async () => {
|
||||||
|
// First confirm it
|
||||||
|
await rental.update({ status: 'confirmed' });
|
||||||
|
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
// API allows re-confirming (idempotent operation)
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/rentals/${rental.id}/status`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ status: 'confirmed' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Status should remain confirmed
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('confirmed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /rentals/:id/decline', () => {
|
||||||
|
let rental;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rental = await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to decline a pending rental', async () => {
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/rentals/${rental.id}/decline`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ reason: 'Item not available for those dates' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe('declined');
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('declined');
|
||||||
|
expect(rental.declineReason).toBe('Item not available for those dates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow declining already declined rental', async () => {
|
||||||
|
await rental.update({ status: 'declined' });
|
||||||
|
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/rentals/${rental.id}/decline`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ reason: 'Already declined' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /rentals/:id/cancel', () => {
|
||||||
|
let rental;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
|
status: 'confirmed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow renter to cancel their rental', async () => {
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ reason: 'Change of plans' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Response format is { rental: {...}, refund: {...} }
|
||||||
|
expect(response.body.rental.status).toBe('cancelled');
|
||||||
|
expect(response.body.rental.cancelledBy).toBe('renter');
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('cancelled');
|
||||||
|
expect(rental.cancelledBy).toBe('renter');
|
||||||
|
expect(rental.cancelledAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to cancel their rental', async () => {
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ reason: 'Item broken' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.rental.status).toBe('cancelled');
|
||||||
|
expect(response.body.rental.cancelledBy).toBe('owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow cancelling completed rental', async () => {
|
||||||
|
await rental.update({ status: 'completed', paymentStatus: 'paid' });
|
||||||
|
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
// RefundService throws error which becomes 500 via next(error)
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ reason: 'Too late' });
|
||||||
|
|
||||||
|
// Expect error (could be 400 or 500 depending on error middleware)
|
||||||
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow unauthorized user to cancel rental', async () => {
|
||||||
|
const otherUser = await createTestUser({ email: 'other@example.com' });
|
||||||
|
const token = generateAuthToken(otherUser);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({ reason: 'Not my rental' });
|
||||||
|
|
||||||
|
// Expect error (could be 403 or 500 depending on error middleware)
|
||||||
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /rentals/pending-requests-count', () => {
|
||||||
|
it('should return count of pending rental requests for owner', async () => {
|
||||||
|
// Create multiple pending rentals
|
||||||
|
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' });
|
||||||
|
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' });
|
||||||
|
await createTestRental(item.id, renter.id, owner.id, { status: 'confirmed' });
|
||||||
|
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/pending-requests-count')
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for user with no pending requests', async () => {
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/rentals/pending-requests-count')
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rental Lifecycle', () => {
|
||||||
|
it('should complete full rental lifecycle: pending -> confirmed -> active -> completed', async () => {
|
||||||
|
// Create pending free rental (totalAmount: 0 is default)
|
||||||
|
const rental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
|
status: 'pending',
|
||||||
|
startDateTime: new Date(Date.now() - 60 * 60 * 1000), // Started 1 hour ago
|
||||||
|
endDateTime: new Date(Date.now() + 60 * 60 * 1000), // Ends in 1 hour
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerToken = generateAuthToken(owner);
|
||||||
|
|
||||||
|
// Step 1: Owner confirms rental (works for free rentals)
|
||||||
|
let response = await request(app)
|
||||||
|
.put(`/rentals/${rental.id}/status`)
|
||||||
|
.set('Cookie', [`accessToken=${ownerToken}`])
|
||||||
|
.send({ status: 'confirmed' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe('confirmed');
|
||||||
|
|
||||||
|
// Step 2: Rental becomes active (typically done by system/webhook)
|
||||||
|
await rental.update({ status: 'active' });
|
||||||
|
|
||||||
|
// Verify status
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('active');
|
||||||
|
|
||||||
|
// Step 3: Owner marks rental as completed
|
||||||
|
response = await request(app)
|
||||||
|
.post(`/rentals/${rental.id}/mark-completed`)
|
||||||
|
.set('Cookie', [`accessToken=${ownerToken}`])
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe('completed');
|
||||||
|
|
||||||
|
// Verify final state
|
||||||
|
await rental.reload();
|
||||||
|
expect(rental.status).toBe('completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Review System', () => {
|
||||||
|
let completedRental;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
completedRental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
|
status: 'completed',
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow renter to review item', async () => {
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${completedRental.id}/review-item`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({
|
||||||
|
rating: 5,
|
||||||
|
review: 'Great item, worked perfectly!',
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
await completedRental.reload();
|
||||||
|
expect(completedRental.itemRating).toBe(5);
|
||||||
|
expect(completedRental.itemReview).toBe('Great item, worked perfectly!');
|
||||||
|
expect(completedRental.itemReviewSubmittedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow owner to review renter', async () => {
|
||||||
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${completedRental.id}/review-renter`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({
|
||||||
|
rating: 4,
|
||||||
|
review: 'Good renter, returned on time.',
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
await completedRental.reload();
|
||||||
|
expect(completedRental.renterRating).toBe(4);
|
||||||
|
expect(completedRental.renterReview).toBe('Good renter, returned on time.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow review of non-completed rental', async () => {
|
||||||
|
const pendingRental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${pendingRental.id}/review-item`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({
|
||||||
|
rating: 5,
|
||||||
|
review: 'Cannot review yet',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow duplicate reviews', async () => {
|
||||||
|
// First review
|
||||||
|
await completedRental.update({
|
||||||
|
itemRating: 5,
|
||||||
|
itemReview: 'First review',
|
||||||
|
itemReviewSubmittedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/rentals/${completedRental.id}/review-item`)
|
||||||
|
.set('Cookie', [`accessToken=${token}`])
|
||||||
|
.send({
|
||||||
|
rating: 3,
|
||||||
|
review: 'Second review attempt',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error).toContain('already');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Database Constraints', () => {
|
||||||
|
it('should not allow rental with invalid item ID', async () => {
|
||||||
|
await expect(
|
||||||
|
createTestRental('00000000-0000-0000-0000-000000000000', renter.id, owner.id)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow rental with invalid user IDs', async () => {
|
||||||
|
await expect(
|
||||||
|
createTestRental(item.id, '00000000-0000-0000-0000-000000000000', owner.id)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cascade delete rentals when item is deleted', async () => {
|
||||||
|
const rental = await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
|
||||||
|
// Delete the item
|
||||||
|
await item.destroy();
|
||||||
|
|
||||||
|
// Rental should also be deleted (due to foreign key constraint)
|
||||||
|
const deletedRental = await Rental.findByPk(rental.id);
|
||||||
|
expect(deletedRental).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Concurrent Operations', () => {
|
||||||
|
it('should handle concurrent status updates (last write wins)', async () => {
|
||||||
|
const rental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerToken = generateAuthToken(owner);
|
||||||
|
|
||||||
|
// Simulate concurrent confirm and decline requests
|
||||||
|
const [confirmResult, declineResult] = await Promise.allSettled([
|
||||||
|
request(app)
|
||||||
|
.put(`/rentals/${rental.id}/status`)
|
||||||
|
.set('Cookie', [`accessToken=${ownerToken}`])
|
||||||
|
.send({ status: 'confirmed' }),
|
||||||
|
request(app)
|
||||||
|
.put(`/rentals/${rental.id}/decline`)
|
||||||
|
.set('Cookie', [`accessToken=${ownerToken}`])
|
||||||
|
.send({ reason: 'Declining instead' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Both requests may succeed (no optimistic locking)
|
||||||
|
// Verify rental ends up in a valid state
|
||||||
|
await rental.reload();
|
||||||
|
expect(['confirmed', 'declined']).toContain(rental.status);
|
||||||
|
|
||||||
|
// At least one should have succeeded
|
||||||
|
const successes = [confirmResult, declineResult].filter(
|
||||||
|
r => r.status === 'fulfilled' && r.value.status === 200
|
||||||
|
);
|
||||||
|
expect(successes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -319,7 +319,7 @@ describe('Auth Routes', () => {
|
|||||||
email: 'test@gmail.com',
|
email: 'test@gmail.com',
|
||||||
firstName: 'Test',
|
firstName: 'Test',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
profileImage: 'profile.jpg'
|
imageFilename: 'profile.jpg'
|
||||||
};
|
};
|
||||||
|
|
||||||
User.create.mockResolvedValue(newUser);
|
User.create.mockResolvedValue(newUser);
|
||||||
@@ -338,7 +338,7 @@ describe('Auth Routes', () => {
|
|||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
authProvider: 'google',
|
authProvider: 'google',
|
||||||
providerId: 'google123',
|
providerId: 'google123',
|
||||||
profileImage: 'profile.jpg',
|
imageFilename: 'profile.jpg',
|
||||||
username: 'test_gle123'
|
username: 'test_gle123'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -785,7 +785,7 @@ describe('Auth Routes', () => {
|
|||||||
email: 'oauth@gmail.com',
|
email: 'oauth@gmail.com',
|
||||||
firstName: 'OAuth',
|
firstName: 'OAuth',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
profileImage: 'pic.jpg',
|
imageFilename: 'pic.jpg',
|
||||||
isVerified: true
|
isVerified: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ describe('Items Routes', () => {
|
|||||||
{
|
{
|
||||||
model: mockUserModel,
|
model: mockUserModel,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -608,7 +608,7 @@ describe('Items Routes', () => {
|
|||||||
{
|
{
|
||||||
model: mockUserModel,
|
model: mockUserModel,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -679,7 +679,7 @@ describe('Items Routes', () => {
|
|||||||
{
|
{
|
||||||
model: mockUserModel,
|
model: mockUserModel,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe('Messages Routes', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
lastName: 'Smith',
|
lastName: 'Smith',
|
||||||
profileImage: 'jane.jpg'
|
imageFilename: 'jane.jpg'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,7 +77,7 @@ describe('Messages Routes', () => {
|
|||||||
id: 3,
|
id: 3,
|
||||||
firstName: 'Bob',
|
firstName: 'Bob',
|
||||||
lastName: 'Johnson',
|
lastName: 'Johnson',
|
||||||
profileImage: null
|
imageFilename: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -95,7 +95,7 @@ describe('Messages Routes', () => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'sender',
|
as: 'sender',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -127,7 +127,7 @@ describe('Messages Routes', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
lastName: 'Smith',
|
lastName: 'Smith',
|
||||||
profileImage: 'jane.jpg'
|
imageFilename: 'jane.jpg'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -145,7 +145,7 @@ describe('Messages Routes', () => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'receiver',
|
as: 'receiver',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
@@ -175,13 +175,13 @@ describe('Messages Routes', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
lastName: 'Smith',
|
lastName: 'Smith',
|
||||||
profileImage: 'jane.jpg'
|
imageFilename: 'jane.jpg'
|
||||||
},
|
},
|
||||||
receiver: {
|
receiver: {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
imageFilename: 'john.jpg'
|
||||||
},
|
},
|
||||||
update: jest.fn()
|
update: jest.fn()
|
||||||
};
|
};
|
||||||
@@ -208,13 +208,13 @@ describe('Messages Routes', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
lastName: 'Smith',
|
lastName: 'Smith',
|
||||||
profileImage: 'jane.jpg'
|
imageFilename: 'jane.jpg'
|
||||||
},
|
},
|
||||||
receiver: {
|
receiver: {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
imageFilename: 'john.jpg'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||||
@@ -239,13 +239,13 @@ describe('Messages Routes', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
lastName: 'Smith',
|
lastName: 'Smith',
|
||||||
profileImage: 'jane.jpg'
|
imageFilename: 'jane.jpg'
|
||||||
},
|
},
|
||||||
receiver: {
|
receiver: {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
imageFilename: 'john.jpg'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(mockMessage.update).not.toHaveBeenCalled();
|
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||||
@@ -304,7 +304,7 @@ describe('Messages Routes', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
imageFilename: 'john.jpg'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ describe('Messages Routes', () => {
|
|||||||
senderId: 1,
|
senderId: 1,
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
content: 'Hello Jane!',
|
content: 'Hello Jane!',
|
||||||
imagePath: null
|
imageFilename: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ describe('Rentals Routes', () => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'owner',
|
as: 'owner',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName'],
|
attributes: ['id', 'firstName', 'lastName'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
@@ -174,7 +174,7 @@ describe('Rentals Routes', () => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'renter',
|
as: 'renter',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName'],
|
attributes: ['id', 'firstName', 'lastName'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('Users Routes', () => {
|
|||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
phone: '555-1234',
|
phone: '555-1234',
|
||||||
profileImage: 'profile.jpg',
|
imageFilename: 'profile.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
@@ -397,7 +397,7 @@ describe('Users Routes', () => {
|
|||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
lastName: 'Smith',
|
lastName: 'Smith',
|
||||||
username: 'janesmith',
|
username: 'janesmith',
|
||||||
profileImage: 'jane.jpg',
|
imageFilename: 'jane.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUserFindByPk.mockResolvedValue(mockUser);
|
mockUserFindByPk.mockResolvedValue(mockUser);
|
||||||
@@ -536,7 +536,7 @@ describe('Users Routes', () => {
|
|||||||
describe('POST /profile/image', () => {
|
describe('POST /profile/image', () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
profileImage: 'old-image.jpg',
|
imageFilename: 'old-image.jpg',
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -559,7 +559,7 @@ describe('Users Routes', () => {
|
|||||||
});
|
});
|
||||||
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
|
expect(fs.unlink).toHaveBeenCalled(); // Old image deleted
|
||||||
expect(mockUser.update).toHaveBeenCalledWith({
|
expect(mockUser.update).toHaveBeenCalledWith({
|
||||||
profileImage: 'test-profile.jpg'
|
imageFilename: 'test-profile.jpg'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ describe('Users Routes', () => {
|
|||||||
|
|
||||||
const userWithoutImage = {
|
const userWithoutImage = {
|
||||||
id: 1,
|
id: 1,
|
||||||
profileImage: null,
|
imageFilename: null,
|
||||||
update: jest.fn().mockResolvedValue()
|
update: jest.fn().mockResolvedValue()
|
||||||
};
|
};
|
||||||
mockUserFindByPk.mockResolvedValue(userWithoutImage);
|
mockUserFindByPk.mockResolvedValue(userWithoutImage);
|
||||||
@@ -638,7 +638,7 @@ describe('Users Routes', () => {
|
|||||||
|
|
||||||
const userWithImage = {
|
const userWithImage = {
|
||||||
id: 1,
|
id: 1,
|
||||||
profileImage: 'old-image.jpg',
|
imageFilename: 'old-image.jpg',
|
||||||
update: jest.fn().mockResolvedValue()
|
update: jest.fn().mockResolvedValue()
|
||||||
};
|
};
|
||||||
mockUserFindByPk.mockResolvedValue(userWithImage);
|
mockUserFindByPk.mockResolvedValue(userWithImage);
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ describe('ConditionCheckService', () => {
|
|||||||
include: [{
|
include: [{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'submittedByUser',
|
as: 'submittedByUser',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
}],
|
}],
|
||||||
order: [['submittedAt', 'ASC']]
|
order: [['submittedAt', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ module.exports = {
|
|||||||
'!src/reportWebVitals.ts',
|
'!src/reportWebVitals.ts',
|
||||||
'!src/**/*.d.ts',
|
'!src/**/*.d.ts',
|
||||||
'!src/setupTests.ts',
|
'!src/setupTests.ts',
|
||||||
'!src/test-polyfills.js'
|
'!src/test-polyfills.js',
|
||||||
|
'!src/mocks/**'
|
||||||
],
|
],
|
||||||
coverageReporters: ['text', 'lcov', 'html'],
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||||
@@ -15,7 +16,7 @@ module.exports = {
|
|||||||
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
|
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!(axios|@stripe)/)'
|
'/node_modules/(?!(axios|@stripe)/).*'
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
|
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
|
||||||
|
|||||||
25
frontend/jest.env.js
Normal file
25
frontend/jest.env.js
Normal 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;
|
||||||
57
frontend/package-lock.json
generated
57
frontend/package-lock.json
generated
@@ -33,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/google.maps": "^3.58.1",
|
"@types/google.maps": "^3.58.1",
|
||||||
|
"cross-fetch": "^4.1.0",
|
||||||
"dotenv-cli": "^9.0.0",
|
"dotenv-cli": "^9.0.0",
|
||||||
"msw": "^2.11.2"
|
"msw": "^2.11.2"
|
||||||
}
|
}
|
||||||
@@ -6238,6 +6239,16 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -12174,6 +12185,52 @@
|
|||||||
"tslib": "^2.0.3"
|
"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": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/google.maps": "^3.58.1",
|
"@types/google.maps": "^3.58.1",
|
||||||
|
"cross-fetch": "^4.1.0",
|
||||||
"dotenv-cli": "^9.0.0",
|
"dotenv-cli": "^9.0.0",
|
||||||
"msw": "^2.11.2"
|
"msw": "^2.11.2"
|
||||||
}
|
}
|
||||||
|
|||||||
82
frontend/src/__mocks__/axios.ts
Normal file
82
frontend/src/__mocks__/axios.ts
Normal 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';
|
||||||
461
frontend/src/__tests__/contexts/AuthContext.test.tsx
Normal file
461
frontend/src/__tests__/contexts/AuthContext.test.tsx
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
346
frontend/src/__tests__/hooks/useAddressAutocomplete.test.ts
Normal file
346
frontend/src/__tests__/hooks/useAddressAutocomplete.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
211
frontend/src/__tests__/services/api.test.ts
Normal file
211
frontend/src/__tests__/services/api.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -437,9 +437,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
|
<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">
|
<div className="d-flex align-items-center">
|
||||||
{recipient.profileImage ? (
|
{recipient.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={recipient.profileImage}
|
src={recipient.imageFilename}
|
||||||
alt={`${recipient.firstName} ${recipient.lastName}`}
|
alt={`${recipient.firstName} ${recipient.lastName}`}
|
||||||
className="rounded-circle me-2"
|
className="rounded-circle me-2"
|
||||||
style={{ width: "35px", height: "35px", objectFit: "cover" }}
|
style={{ width: "35px", height: "35px", objectFit: "cover" }}
|
||||||
@@ -525,10 +525,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message.imagePath && (
|
{message.imageFilename && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<img
|
<img
|
||||||
src={getMessageImageUrl(message.imagePath)}
|
src={getMessageImageUrl(message.imageFilename)}
|
||||||
alt="Shared image"
|
alt="Shared image"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -539,7 +539,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(
|
window.open(
|
||||||
getMessageImageUrl(message.imagePath!),
|
getMessageImageUrl(message.imageFilename!),
|
||||||
"_blank"
|
"_blank"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,9 +212,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
{comment.content}
|
{comment.content}
|
||||||
</p>
|
</p>
|
||||||
{comment.images && comment.images.length > 0 && (
|
{comment.imageFilenames && comment.imageFilenames.length > 0 && (
|
||||||
<div className="row g-2 mb-2">
|
<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">
|
<div key={index} className="col-4 col-md-3">
|
||||||
<img
|
<img
|
||||||
src={getForumImageUrl(image)}
|
src={getForumImageUrl(image)}
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ const ItemCard: React.FC<ItemCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Link to={`/items/${item.id}`} className="text-decoration-none">
|
<Link to={`/items/${item.id}`} className="text-decoration-none">
|
||||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||||
{item.images && item.images[0] ? (
|
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||||
<img
|
<img
|
||||||
src={item.images[0]}
|
src={item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails })
|
|||||||
return (
|
return (
|
||||||
<div style={{ width: 'min(280px, 90vw)', maxWidth: '280px' }}>
|
<div style={{ width: 'min(280px, 90vw)', maxWidth: '280px' }}>
|
||||||
<div className="card border-0">
|
<div className="card border-0">
|
||||||
{item.images && item.images[0] ? (
|
{item.imageFilenames && item.imageFilenames[0] ? (
|
||||||
<img
|
<img
|
||||||
src={item.images[0]}
|
src={item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
|
|||||||
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
|
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{rental.renter?.profileImage ? (
|
{rental.renter?.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={rental.renter.profileImage}
|
src={rental.renter.imageFilename}
|
||||||
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
||||||
className="rounded-circle"
|
className="rounded-circle"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
|
|||||||
{rental.owner && rental.item && (
|
{rental.owner && rental.item && (
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<div className="d-flex justify-content-center mb-3">
|
<div className="d-flex justify-content-center mb-3">
|
||||||
{rental.owner.profileImage ? (
|
{rental.owner.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={rental.owner.profileImage}
|
src={rental.owner.imageFilename}
|
||||||
alt={`${rental.owner.firstName} ${rental.owner.lastName}`}
|
alt={`${rental.owner.firstName} ${rental.owner.lastName}`}
|
||||||
className="rounded-circle"
|
className="rounded-circle"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
|
|||||||
{rental.renter && rental.item && (
|
{rental.renter && rental.item && (
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<div className="d-flex justify-content-center mb-3">
|
<div className="d-flex justify-content-center mb-3">
|
||||||
{rental.renter.profileImage ? (
|
{rental.renter.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={rental.renter.profileImage}
|
src={rental.renter.imageFilename}
|
||||||
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
|
||||||
className="rounded-circle"
|
className="rounded-circle"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
72
frontend/src/mocks/handlers.ts
Normal file
72
frontend/src/mocks/handlers.ts
Normal 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;
|
||||||
|
};
|
||||||
43
frontend/src/mocks/server.ts
Normal file
43
frontend/src/mocks/server.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -162,8 +162,8 @@ const EditItem: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set existing images as previews
|
// Set existing images as previews
|
||||||
if (item.images && item.images.length > 0) {
|
if (item.imageFilenames && item.imageFilenames.length > 0) {
|
||||||
setImagePreviews(item.images);
|
setImagePreviews(item.imageFilenames);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which pricing unit to select based on existing data
|
// Determine which pricing unit to select based on existing data
|
||||||
|
|||||||
@@ -343,9 +343,9 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
{post.content}
|
{post.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{post.images && post.images.length > 0 && (
|
{post.imageFilenames && post.imageFilenames.length > 0 && (
|
||||||
<div className="row g-2 mb-3">
|
<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">
|
<div key={index} className="col-6 col-md-4">
|
||||||
<img
|
<img
|
||||||
src={getForumImageUrl(image)}
|
src={getForumImageUrl(image)}
|
||||||
|
|||||||
@@ -414,10 +414,10 @@ const ItemDetail: React.FC = () => {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-8">
|
<div className="col-md-8">
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
{item.images.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img
|
<img
|
||||||
src={item.images[selectedImage]}
|
src={item.imageFilenames[selectedImage]}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
style={{
|
style={{
|
||||||
@@ -426,9 +426,9 @@ const ItemDetail: React.FC = () => {
|
|||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{item.images.length > 1 && (
|
{item.imageFilenames.length > 1 && (
|
||||||
<div className="d-flex gap-2 overflow-auto justify-content-center">
|
<div className="d-flex gap-2 overflow-auto justify-content-center">
|
||||||
{item.images.map((image, index) => (
|
{item.imageFilenames.map((image, index) => (
|
||||||
<img
|
<img
|
||||||
key={index}
|
key={index}
|
||||||
src={image}
|
src={image}
|
||||||
@@ -478,9 +478,9 @@ const ItemDetail: React.FC = () => {
|
|||||||
onClick={() => navigate(`/users/${item.ownerId}`)}
|
onClick={() => navigate(`/users/${item.ownerId}`)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{item.owner.profileImage ? (
|
{item.owner.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={item.owner.profileImage}
|
src={item.owner.imageFilename}
|
||||||
alt={`${item.owner.firstName} ${item.owner.lastName}`}
|
alt={`${item.owner.firstName} ${item.owner.lastName}`}
|
||||||
className="rounded-circle me-2"
|
className="rounded-circle me-2"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ const Messages: React.FC = () => {
|
|||||||
<div className="d-flex w-100 justify-content-between align-items-start">
|
<div className="d-flex w-100 justify-content-between align-items-start">
|
||||||
<div className="d-flex align-items-center flex-grow-1">
|
<div className="d-flex align-items-center flex-grow-1">
|
||||||
{/* Profile Picture */}
|
{/* Profile Picture */}
|
||||||
{conversation.partner.profileImage ? (
|
{conversation.partner.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={conversation.partner.profileImage}
|
src={conversation.partner.imageFilename}
|
||||||
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
|
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
|
||||||
className="rounded-circle me-3"
|
className="rounded-circle me-3"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -306,9 +306,9 @@ const Owning: React.FC = () => {
|
|||||||
{allOwnerRentals.map((rental) => (
|
{allOwnerRentals.map((rental) => (
|
||||||
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
|
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
|
||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.images && rental.item.images[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.images[0]}
|
src={rental.item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{ height: "200px", objectFit: "cover" }}
|
style={{ height: "200px", objectFit: "cover" }}
|
||||||
@@ -527,9 +527,9 @@ const Owning: React.FC = () => {
|
|||||||
navigate(`/items/${item.id}`);
|
navigate(`/items/${item.id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.images && item.images[0] && (
|
{item.imageFilenames && item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={item.images[0]}
|
src={item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{ height: "200px", objectFit: "cover" }}
|
style={{ height: "200px", objectFit: "cover" }}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const Profile: React.FC = () => {
|
|||||||
state: string;
|
state: string;
|
||||||
zipCode: string;
|
zipCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
profileImage: string;
|
imageFilename: string;
|
||||||
itemRequestNotificationRadius: number | null;
|
itemRequestNotificationRadius: number | null;
|
||||||
}>({
|
}>({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
@@ -52,7 +52,7 @@ const Profile: React.FC = () => {
|
|||||||
state: "",
|
state: "",
|
||||||
zipCode: "",
|
zipCode: "",
|
||||||
country: "",
|
country: "",
|
||||||
profileImage: "",
|
imageFilename: "",
|
||||||
itemRequestNotificationRadius: 10,
|
itemRequestNotificationRadius: 10,
|
||||||
});
|
});
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
@@ -156,12 +156,12 @@ const Profile: React.FC = () => {
|
|||||||
state: response.data.state || "",
|
state: response.data.state || "",
|
||||||
zipCode: response.data.zipCode || "",
|
zipCode: response.data.zipCode || "",
|
||||||
country: response.data.country || "",
|
country: response.data.country || "",
|
||||||
profileImage: response.data.profileImage || "",
|
imageFilename: response.data.imageFilename || "",
|
||||||
itemRequestNotificationRadius:
|
itemRequestNotificationRadius:
|
||||||
response.data.itemRequestNotificationRadius || 10,
|
response.data.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
if (response.data.profileImage) {
|
if (response.data.imageFilename) {
|
||||||
setImagePreview(getImageUrl(response.data.profileImage));
|
setImagePreview(getImageUrl(response.data.imageFilename));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || "Failed to fetch profile");
|
setError(err.response?.data?.message || "Failed to fetch profile");
|
||||||
@@ -304,14 +304,14 @@ const Profile: React.FC = () => {
|
|||||||
// Upload image immediately
|
// Upload image immediately
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("profileImage", file);
|
formData.append("imageFilename", file);
|
||||||
|
|
||||||
const response = await userAPI.uploadProfileImage(formData);
|
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) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
profileImage: response.data.filename,
|
imageFilename: response.data.filename,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Update preview to use the uploaded image URL
|
// Update preview to use the uploaded image URL
|
||||||
@@ -322,8 +322,8 @@ const Profile: React.FC = () => {
|
|||||||
// Reset on error
|
// Reset on error
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData?.profileImage
|
profileData?.imageFilename
|
||||||
? getImageUrl(profileData.profileImage)
|
? getImageUrl(profileData.imageFilename)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -336,8 +336,8 @@ const Profile: React.FC = () => {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Don't send profileImage in the update data as it's handled separately
|
// Don't send imageFilename in the update data as it's handled separately
|
||||||
const { profileImage, ...updateData } = formData;
|
const { imageFilename, ...updateData } = formData;
|
||||||
|
|
||||||
const response = await userAPI.updateProfile(updateData);
|
const response = await userAPI.updateProfile(updateData);
|
||||||
setProfileData(response.data);
|
setProfileData(response.data);
|
||||||
@@ -379,12 +379,12 @@ const Profile: React.FC = () => {
|
|||||||
state: profileData.state || "",
|
state: profileData.state || "",
|
||||||
zipCode: profileData.zipCode || "",
|
zipCode: profileData.zipCode || "",
|
||||||
country: profileData.country || "",
|
country: profileData.country || "",
|
||||||
profileImage: profileData.profileImage || "",
|
imageFilename: profileData.imageFilename || "",
|
||||||
itemRequestNotificationRadius:
|
itemRequestNotificationRadius:
|
||||||
profileData.itemRequestNotificationRadius || 10,
|
profileData.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
|
profileData.imageFilename ? getImageUrl(profileData.imageFilename) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -774,7 +774,7 @@ const Profile: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{editing && (
|
{editing && (
|
||||||
<label
|
<label
|
||||||
htmlFor="profileImageOverview"
|
htmlFor="imageFilenameOverview"
|
||||||
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
||||||
style={{
|
style={{
|
||||||
width: "35px",
|
width: "35px",
|
||||||
@@ -785,7 +785,7 @@ const Profile: React.FC = () => {
|
|||||||
<i className="bi bi-camera-fill"></i>
|
<i className="bi bi-camera-fill"></i>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="profileImageOverview"
|
id="imageFilenameOverview"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
className="d-none"
|
className="d-none"
|
||||||
@@ -1222,9 +1222,9 @@ const Profile: React.FC = () => {
|
|||||||
className="col-md-6 col-lg-4 mb-4"
|
className="col-md-6 col-lg-4 mb-4"
|
||||||
>
|
>
|
||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.images && rental.item.images[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.images[0]}
|
src={rental.item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{
|
style={{
|
||||||
@@ -1359,9 +1359,9 @@ const Profile: React.FC = () => {
|
|||||||
className="col-md-6 col-lg-4 mb-4"
|
className="col-md-6 col-lg-4 mb-4"
|
||||||
>
|
>
|
||||||
<div className="card h-100">
|
<div className="card h-100">
|
||||||
{rental.item?.images && rental.item.images[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.images[0]}
|
src={rental.item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ const PublicProfile: React.FC = () => {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
{user.profileImage ? (
|
{user.imageFilename ? (
|
||||||
<img
|
<img
|
||||||
src={user.profileImage}
|
src={user.imageFilename}
|
||||||
alt={`${user.firstName} ${user.lastName}`}
|
alt={`${user.firstName} ${user.lastName}`}
|
||||||
className="rounded-circle mb-3"
|
className="rounded-circle mb-3"
|
||||||
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
||||||
@@ -111,9 +111,9 @@ const PublicProfile: React.FC = () => {
|
|||||||
onClick={() => navigate(`/items/${item.id}`)}
|
onClick={() => navigate(`/items/${item.id}`)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{item.images.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<img
|
<img
|
||||||
src={item.images[0]}
|
src={item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
style={{ height: '200px', objectFit: 'cover' }}
|
style={{ height: '200px', objectFit: 'cover' }}
|
||||||
|
|||||||
@@ -341,9 +341,9 @@ const RentItem: React.FC = () => {
|
|||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{item.images && item.images[0] && (
|
{item.imageFilenames && item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={item.images[0]}
|
src={item.imageFilenames[0]}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ const Renting: React.FC = () => {
|
|||||||
className="card h-100"
|
className="card h-100"
|
||||||
style={{ cursor: rental.item ? "pointer" : "default" }}
|
style={{ cursor: rental.item ? "pointer" : "default" }}
|
||||||
>
|
>
|
||||||
{rental.item?.images && rental.item.images[0] && (
|
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
|
||||||
<img
|
<img
|
||||||
src={rental.item.images[0]}
|
src={rental.item.imageFilenames[0]}
|
||||||
className="card-img-top"
|
className="card-img-top"
|
||||||
alt={rental.item.name}
|
alt={rental.item.name}
|
||||||
style={{ height: "200px", objectFit: "cover" }}
|
style={{ height: "200px", objectFit: "cover" }}
|
||||||
|
|||||||
@@ -3,3 +3,53 @@
|
|||||||
// expect(element).toHaveTextContent(/react/i)
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
import '@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;
|
||||||
|
});
|
||||||
|
|||||||
26
frontend/src/test-polyfills.js
Normal file
26
frontend/src/test-polyfills.js
Normal 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;
|
||||||
@@ -27,7 +27,7 @@ export interface User {
|
|||||||
state?: string;
|
state?: string;
|
||||||
zipCode?: string;
|
zipCode?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
profileImage?: string;
|
imageFilename?: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
role?: "user" | "admin";
|
role?: "user" | "admin";
|
||||||
stripeConnectedAccountId?: string;
|
stripeConnectedAccountId?: string;
|
||||||
@@ -41,7 +41,7 @@ export interface Message {
|
|||||||
receiverId: string;
|
receiverId: string;
|
||||||
content: string;
|
content: string;
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
imagePath?: string;
|
imageFilename?: string;
|
||||||
sender?: User;
|
sender?: User;
|
||||||
receiver?: User;
|
receiver?: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -84,7 +84,7 @@ export interface Item {
|
|||||||
country?: string;
|
country?: string;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
images: string[];
|
imageFilenames: string[];
|
||||||
condition: "excellent" | "good" | "fair" | "poor";
|
condition: "excellent" | "good" | "fair" | "poor";
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
rules?: string;
|
rules?: string;
|
||||||
@@ -187,7 +187,7 @@ export interface ConditionCheck {
|
|||||||
| "rental_start_renter"
|
| "rental_start_renter"
|
||||||
| "rental_end_renter"
|
| "rental_end_renter"
|
||||||
| "post_rental_owner";
|
| "post_rental_owner";
|
||||||
photos: string[];
|
imageFilenames: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
submittedBy: string;
|
submittedBy: string;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
@@ -212,7 +212,7 @@ export interface DamageAssessment {
|
|||||||
needsReplacement: boolean;
|
needsReplacement: boolean;
|
||||||
replacementCost?: number;
|
replacementCost?: number;
|
||||||
proofOfOwnership?: string[];
|
proofOfOwnership?: string[];
|
||||||
photos?: string[];
|
imageFilenames?: string[];
|
||||||
assessedAt: string;
|
assessedAt: string;
|
||||||
assessedBy: string;
|
assessedBy: string;
|
||||||
feeCalculation: {
|
feeCalculation: {
|
||||||
@@ -265,7 +265,7 @@ export interface ForumPost {
|
|||||||
commentCount: number;
|
commentCount: number;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
acceptedAnswerId?: string;
|
acceptedAnswerId?: string;
|
||||||
images?: string[];
|
imageFilenames?: string[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
@@ -287,7 +287,7 @@ export interface ForumComment {
|
|||||||
content: string;
|
content: string;
|
||||||
parentCommentId?: string;
|
parentCommentId?: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
images?: string[];
|
imageFilenames?: string[];
|
||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
author?: User;
|
author?: User;
|
||||||
|
|||||||
Reference in New Issue
Block a user