From 11593606aa96ca57ab4434d906aeb22e47c6d133 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:13:23 -0500 Subject: [PATCH] imageFilenames and imageFilename, backend integration tests, frontend tests, removed username references --- ...251126000001-alter-items-images-to-text.js | 19 + ...251126000002-alter-image-fields-to-text.js | 39 ++ ...000003-rename-image-fields-consistently.js | 24 + backend/models/ConditionCheck.js | 4 +- backend/models/ForumComment.js | 2 +- backend/models/ForumPost.js | 2 +- backend/models/Item.js | 4 +- backend/models/Message.js | 4 +- backend/models/User.js | 4 +- backend/routes/auth.js | 18 +- backend/routes/conditionChecks.js | 8 +- backend/routes/forum.js | 20 +- backend/routes/messages.js | 20 +- backend/routes/users.js | 6 +- backend/services/conditionCheckService.js | 14 +- .../integration/auth.integration.test.js | 621 ++++++++++++++++++ .../integration/rental.integration.test.js | 585 +++++++++++++++++ backend/tests/unit/routes/auth.test.js | 6 +- backend/tests/unit/routes/items.test.js | 6 +- backend/tests/unit/routes/messages.test.js | 26 +- backend/tests/unit/routes/rentals.test.js | 4 +- backend/tests/unit/routes/users.test.js | 12 +- .../services/conditionCheckService.test.js | 2 +- frontend/jest.config.js | 5 +- frontend/jest.env.js | 25 + frontend/package-lock.json | 57 ++ frontend/package.json | 1 + frontend/src/__mocks__/axios.ts | 82 +++ .../__tests__/contexts/AuthContext.test.tsx | 461 +++++++++++++ .../hooks/useAddressAutocomplete.test.ts | 346 ++++++++++ frontend/src/__tests__/services/api.test.ts | 211 ++++++ frontend/src/components/ChatWindow.tsx | 10 +- frontend/src/components/CommentThread.tsx | 4 +- frontend/src/components/ItemCard.tsx | 4 +- frontend/src/components/ItemMarkerInfo.tsx | 4 +- frontend/src/components/ItemReviews.tsx | 4 +- frontend/src/components/ReviewModal.tsx | 4 +- frontend/src/components/ReviewRenterModal.tsx | 4 +- frontend/src/mocks/handlers.ts | 72 ++ frontend/src/mocks/server.ts | 43 ++ frontend/src/pages/EditItem.tsx | 4 +- frontend/src/pages/ForumPostDetail.tsx | 4 +- frontend/src/pages/ItemDetail.tsx | 12 +- frontend/src/pages/Messages.tsx | 4 +- frontend/src/pages/Owning.tsx | 8 +- frontend/src/pages/Profile.tsx | 40 +- frontend/src/pages/PublicProfile.tsx | 8 +- frontend/src/pages/RentItem.tsx | 4 +- frontend/src/pages/Renting.tsx | 4 +- frontend/src/setupTests.ts | 50 ++ frontend/src/test-polyfills.js | 26 + frontend/src/types/index.ts | 14 +- 52 files changed, 2815 insertions(+), 150 deletions(-) create mode 100644 backend/migrations/20251126000001-alter-items-images-to-text.js create mode 100644 backend/migrations/20251126000002-alter-image-fields-to-text.js create mode 100644 backend/migrations/20251126000003-rename-image-fields-consistently.js create mode 100644 backend/tests/integration/auth.integration.test.js create mode 100644 backend/tests/integration/rental.integration.test.js create mode 100644 frontend/jest.env.js create mode 100644 frontend/src/__mocks__/axios.ts create mode 100644 frontend/src/__tests__/contexts/AuthContext.test.tsx create mode 100644 frontend/src/__tests__/hooks/useAddressAutocomplete.test.ts create mode 100644 frontend/src/__tests__/services/api.test.ts create mode 100644 frontend/src/mocks/handlers.ts create mode 100644 frontend/src/mocks/server.ts create mode 100644 frontend/src/test-polyfills.js diff --git a/backend/migrations/20251126000001-alter-items-images-to-text.js b/backend/migrations/20251126000001-alter-items-images-to-text.js new file mode 100644 index 0000000..0ebfe6c --- /dev/null +++ b/backend/migrations/20251126000001-alter-items-images-to-text.js @@ -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: [], + }); + }, +}; diff --git a/backend/migrations/20251126000002-alter-image-fields-to-text.js b/backend/migrations/20251126000002-alter-image-fields-to-text.js new file mode 100644 index 0000000..87e098f --- /dev/null +++ b/backend/migrations/20251126000002-alter-image-fields-to-text.js @@ -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: [], + }), + ]); + }, +}; diff --git a/backend/migrations/20251126000003-rename-image-fields-consistently.js b/backend/migrations/20251126000003-rename-image-fields-consistently.js new file mode 100644 index 0000000..690b8c2 --- /dev/null +++ b/backend/migrations/20251126000003-rename-image-fields-consistently.js @@ -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"); + }, +}; diff --git a/backend/models/ConditionCheck.js b/backend/models/ConditionCheck.js index 6bc5e0d..8e3fcd2 100644 --- a/backend/models/ConditionCheck.js +++ b/backend/models/ConditionCheck.js @@ -24,8 +24,8 @@ const ConditionCheck = sequelize.define("ConditionCheck", { ), allowNull: false, }, - photos: { - type: DataTypes.ARRAY(DataTypes.STRING), + imageFilenames: { + type: DataTypes.ARRAY(DataTypes.TEXT), defaultValue: [], }, notes: { diff --git a/backend/models/ForumComment.js b/backend/models/ForumComment.js index 99a951f..6d69c68 100644 --- a/backend/models/ForumComment.js +++ b/backend/models/ForumComment.js @@ -39,7 +39,7 @@ const ForumComment = sequelize.define('ForumComment', { type: DataTypes.BOOLEAN, defaultValue: false }, - images: { + imageFilenames: { type: DataTypes.ARRAY(DataTypes.TEXT), allowNull: true, defaultValue: [] diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js index 096583d..4a15944 100644 --- a/backend/models/ForumPost.js +++ b/backend/models/ForumPost.js @@ -52,7 +52,7 @@ const ForumPost = sequelize.define('ForumPost', { key: 'id' } }, - images: { + imageFilenames: { type: DataTypes.ARRAY(DataTypes.TEXT), allowNull: true, defaultValue: [] diff --git a/backend/models/Item.js b/backend/models/Item.js index 1a1b737..3e3f8f8 100644 --- a/backend/models/Item.js +++ b/backend/models/Item.js @@ -82,8 +82,8 @@ const Item = sequelize.define("Item", { longitude: { type: DataTypes.DECIMAL(11, 8), }, - images: { - type: DataTypes.ARRAY(DataTypes.STRING), + imageFilenames: { + type: DataTypes.ARRAY(DataTypes.TEXT), defaultValue: [], }, isAvailable: { diff --git a/backend/models/Message.js b/backend/models/Message.js index 724f569..26b09ad 100644 --- a/backend/models/Message.js +++ b/backend/models/Message.js @@ -31,8 +31,8 @@ const Message = sequelize.define('Message', { type: DataTypes.BOOLEAN, defaultValue: false }, - imagePath: { - type: DataTypes.STRING, + imageFilename: { + type: DataTypes.TEXT, allowNull: true } }, { diff --git a/backend/models/User.js b/backend/models/User.js index ab86ddf..e2e7895 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -60,8 +60,8 @@ const User = sequelize.define( country: { type: DataTypes.STRING, }, - profileImage: { - type: DataTypes.STRING, + imageFilename: { + type: DataTypes.TEXT, }, isVerified: { type: DataTypes.BOOLEAN, diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 05ab5d3..67ff12a 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -101,12 +101,14 @@ router.post( phone, }); - // Link alpha invitation to user - await alphaInvitation.update({ - usedBy: user.id, - usedAt: new Date(), - status: "active", - }); + // Link alpha invitation to user (only if alpha testing is enabled) + if (alphaInvitation) { + await alphaInvitation.update({ + usedBy: user.id, + usedAt: new Date(), + status: "active", + }); + } // Generate verification token and send email await user.generateVerificationToken(); @@ -367,7 +369,7 @@ router.post( lastName, authProvider: "google", providerId: googleId, - profileImage: picture, + imageFilename: picture, isVerified: true, verifiedAt: new Date(), }); @@ -434,7 +436,7 @@ router.post( email: user.email, firstName: user.firstName, lastName: user.lastName, - profileImage: user.profileImage, + imageFilename: user.imageFilename, isVerified: user.isVerified, role: user.role, }, diff --git a/backend/routes/conditionChecks.js b/backend/routes/conditionChecks.js index 71396c8..cb1812f 100644 --- a/backend/routes/conditionChecks.js +++ b/backend/routes/conditionChecks.js @@ -27,7 +27,7 @@ const upload = multer({ router.post( "/:rentalId", authenticateToken, - upload.array("photos"), + upload.array("imageFilenames"), async (req, res) => { try { const { rentalId } = req.params; @@ -35,13 +35,13 @@ router.post( const userId = req.user.id; // Get uploaded file paths - const photos = req.files ? req.files.map((file) => file.path) : []; + const imageFilenames = req.files ? req.files.map((file) => file.path) : []; const conditionCheck = await ConditionCheckService.submitConditionCheck( rentalId, checkType, userId, - photos, + imageFilenames, notes ); @@ -50,7 +50,7 @@ router.post( rentalId, checkType, userId, - photoCount: photos.length, + photoCount: imageFilenames.length, }); res.status(201).json({ diff --git a/backend/routes/forum.js b/backend/routes/forum.js index bda1ebb..dcb2a71 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -21,7 +21,7 @@ const buildCommentTree = (comments, isAdmin = false) => { // Sanitize deleted comments for non-admin users if (commentJson.isDeleted && !isAdmin) { commentJson.content = ''; - commentJson.images = []; + commentJson.imageFilenames = []; } commentMap[comment.id] = { ...commentJson, replies: [] }; @@ -252,7 +252,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res, } // Extract image filenames if uploaded - const images = req.files ? req.files.map(file => file.filename) : []; + const imageFilenames = req.files ? req.files.map(file => file.filename) : []; // Initialize location fields let latitude = null; @@ -313,7 +313,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res, content, category, authorId: req.user.id, - images, + imageFilenames, zipCode: zipCode || null, latitude, longitude @@ -936,14 +936,14 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, } // Extract image filenames if uploaded - const images = req.files ? req.files.map(file => file.filename) : []; + const imageFilenames = req.files ? req.files.map(file => file.filename) : []; const comment = await ForumComment.create({ postId: req.params.id, authorId: req.user.id, content, parentCommentId: parentCommentId || null, - images + imageFilenames }); // Increment comment count and update post's updatedAt to reflect activity @@ -955,7 +955,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, { model: User, as: 'author', - attributes: ['id', 'username', 'firstName', 'lastName', 'email'] + attributes: ['id', 'firstName', 'lastName', 'email'] } ] }); @@ -1261,7 +1261,7 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r { model: User, as: 'author', - attributes: ['id', 'username', 'firstName', 'lastName', 'email'] + attributes: ['id', 'firstName', 'lastName', 'email'] } ] }); @@ -1380,7 +1380,7 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req { model: User, as: 'author', - attributes: ['id', 'username', 'firstName', 'lastName', 'email'] + attributes: ['id', 'firstName', 'lastName', 'email'] } ] }); @@ -1512,7 +1512,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r { model: User, as: 'author', - attributes: ['id', 'username', 'firstName', 'lastName', 'email'] + attributes: ['id', 'firstName', 'lastName', 'email'] } ] }); @@ -1545,7 +1545,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r (async () => { try { const admin = await User.findByPk(req.user.id, { - attributes: ['id', 'username', 'firstName', 'lastName', 'email'] + attributes: ['id', 'firstName', 'lastName', 'email'] }); // Get all unique participants (author + commenters) diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 5e2869e..9dec864 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -20,7 +20,7 @@ router.get('/', authenticateToken, async (req, res, next) => { { model: User, as: 'sender', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -61,12 +61,12 @@ router.get('/conversations', authenticateToken, async (req, res, next) => { { model: User, as: 'sender', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] }, { model: User, as: 'receiver', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -147,7 +147,7 @@ router.get('/sent', authenticateToken, async (req, res, next) => { { model: User, as: 'receiver', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -186,12 +186,12 @@ router.get('/:id', authenticateToken, async (req, res, next) => { { model: User, as: 'sender', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] }, { model: User, as: 'receiver', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ] }); @@ -253,20 +253,20 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res, next) = } // Extract image filename if uploaded - const imagePath = req.file ? req.file.filename : null; + const imageFilename = req.file ? req.file.filename : null; const message = await Message.create({ senderId: req.user.id, receiverId, content, - imagePath + imageFilename }); const messageWithSender = await Message.findByPk(message.id, { include: [{ model: User, as: 'sender', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] }] }); @@ -398,7 +398,7 @@ router.get('/images/:filename', // Verify user is sender or receiver of a message with this image const message = await Message.findOne({ where: { - imagePath: filename, + imageFilename: filename, [Op.or]: [ { senderId: req.user.id }, { receiverId: req.user.id } diff --git a/backend/routes/users.js b/backend/routes/users.js index fb2fea4..b64a0d4 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -211,8 +211,8 @@ router.post('/profile/image', authenticateToken, (req, res) => { try { // Delete old profile image if exists const user = await User.findByPk(req.user.id); - if (user.profileImage) { - const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage); + if (user.imageFilename) { + const oldImagePath = path.join(__dirname, '../uploads/profiles', user.imageFilename); try { await fs.unlink(oldImagePath); } catch (unlinkErr) { @@ -227,7 +227,7 @@ router.post('/profile/image', authenticateToken, (req, res) => { // Update user with new image filename await user.update({ - profileImage: req.file.filename + imageFilename: req.file.filename }); const reqLogger = logger.withRequestId(req.id); diff --git a/backend/services/conditionCheckService.js b/backend/services/conditionCheckService.js index abed26d..12773c8 100644 --- a/backend/services/conditionCheckService.js +++ b/backend/services/conditionCheckService.js @@ -116,7 +116,7 @@ class ConditionCheckService { * @param {string} rentalId - Rental ID * @param {string} checkType - Type of check * @param {string} userId - User submitting the check - * @param {Array} photos - Array of photo URLs + * @param {Array} imageFilenames - Array of image filenames * @param {string} notes - Optional notes * @returns {Object} - Created condition check */ @@ -124,7 +124,7 @@ class ConditionCheckService { rentalId, checkType, userId, - photos = [], + imageFilenames = [], notes = null ) { // Validate the check @@ -139,7 +139,7 @@ class ConditionCheckService { } // Validate photos (basic validation) - if (photos.length > 20) { + if (imageFilenames.length > 20) { throw new Error("Maximum 20 photos allowed per condition check"); } @@ -147,7 +147,7 @@ class ConditionCheckService { rentalId, checkType, submittedBy: userId, - photos, + imageFilenames, notes, }); @@ -166,7 +166,7 @@ class ConditionCheckService { { model: User, as: "submittedByUser", - attributes: ["id", "username", "firstName", "lastName"], + attributes: ["id", "firstName", "lastName"], }, ], order: [["submittedAt", "ASC"]], @@ -192,7 +192,7 @@ class ConditionCheckService { { model: User, as: "submittedByUser", - attributes: ["id", "username", "firstName", "lastName"], + attributes: ["id", "firstName", "lastName"], }, ], }); @@ -216,7 +216,7 @@ class ConditionCheckService { status: "completed", submittedAt: existingCheck.submittedAt, submittedBy: existingCheck.submittedBy, - photoCount: existingCheck.photos.length, + photoCount: existingCheck.imageFilenames.length, hasNotes: !!existingCheck.notes, }; } else { diff --git a/backend/tests/integration/auth.integration.test.js b/backend/tests/integration/auth.integration.test.js new file mode 100644 index 0000000..999a901 --- /dev/null +++ b/backend/tests/integration/auth.integration.test.js @@ -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'); + }); + }); +}); diff --git a/backend/tests/integration/rental.integration.test.js b/backend/tests/integration/rental.integration.test.js new file mode 100644 index 0000000..efa1706 --- /dev/null +++ b/backend/tests/integration/rental.integration.test.js @@ -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); + }); + }); +}); diff --git a/backend/tests/unit/routes/auth.test.js b/backend/tests/unit/routes/auth.test.js index 13e4576..5ddadfe 100644 --- a/backend/tests/unit/routes/auth.test.js +++ b/backend/tests/unit/routes/auth.test.js @@ -319,7 +319,7 @@ describe('Auth Routes', () => { email: 'test@gmail.com', firstName: 'Test', lastName: 'User', - profileImage: 'profile.jpg' + imageFilename: 'profile.jpg' }; User.create.mockResolvedValue(newUser); @@ -338,7 +338,7 @@ describe('Auth Routes', () => { lastName: 'User', authProvider: 'google', providerId: 'google123', - profileImage: 'profile.jpg', + imageFilename: 'profile.jpg', username: 'test_gle123' }); }); @@ -785,7 +785,7 @@ describe('Auth Routes', () => { email: 'oauth@gmail.com', firstName: 'OAuth', lastName: 'User', - profileImage: 'pic.jpg', + imageFilename: 'pic.jpg', isVerified: true }; diff --git a/backend/tests/unit/routes/items.test.js b/backend/tests/unit/routes/items.test.js index b712606..dee8a6f 100644 --- a/backend/tests/unit/routes/items.test.js +++ b/backend/tests/unit/routes/items.test.js @@ -166,7 +166,7 @@ describe('Items Routes', () => { { model: mockUserModel, as: 'owner', - attributes: ['id', 'username', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName'] } ], limit: 20, @@ -608,7 +608,7 @@ describe('Items Routes', () => { { model: mockUserModel, as: 'owner', - attributes: ['id', 'username', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName'] } ] }); @@ -679,7 +679,7 @@ describe('Items Routes', () => { { model: mockUserModel, as: 'owner', - attributes: ['id', 'username', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName'] } ] }); diff --git a/backend/tests/unit/routes/messages.test.js b/backend/tests/unit/routes/messages.test.js index 1d85ec3..8ebf0ab 100644 --- a/backend/tests/unit/routes/messages.test.js +++ b/backend/tests/unit/routes/messages.test.js @@ -63,7 +63,7 @@ describe('Messages Routes', () => { id: 2, firstName: 'Jane', lastName: 'Smith', - profileImage: 'jane.jpg' + imageFilename: 'jane.jpg' } }, { @@ -77,7 +77,7 @@ describe('Messages Routes', () => { id: 3, firstName: 'Bob', lastName: 'Johnson', - profileImage: null + imageFilename: null } } ]; @@ -95,7 +95,7 @@ describe('Messages Routes', () => { { model: User, as: 'sender', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -127,7 +127,7 @@ describe('Messages Routes', () => { id: 2, firstName: 'Jane', lastName: 'Smith', - profileImage: 'jane.jpg' + imageFilename: 'jane.jpg' } } ]; @@ -145,7 +145,7 @@ describe('Messages Routes', () => { { model: User, as: 'receiver', - attributes: ['id', 'firstName', 'lastName', 'profileImage'] + attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] @@ -175,13 +175,13 @@ describe('Messages Routes', () => { id: 2, firstName: 'Jane', lastName: 'Smith', - profileImage: 'jane.jpg' + imageFilename: 'jane.jpg' }, receiver: { id: 1, firstName: 'John', lastName: 'Doe', - profileImage: 'john.jpg' + imageFilename: 'john.jpg' }, update: jest.fn() }; @@ -208,13 +208,13 @@ describe('Messages Routes', () => { id: 2, firstName: 'Jane', lastName: 'Smith', - profileImage: 'jane.jpg' + imageFilename: 'jane.jpg' }, receiver: { id: 1, firstName: 'John', lastName: 'Doe', - profileImage: 'john.jpg' + imageFilename: 'john.jpg' } }); expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true }); @@ -239,13 +239,13 @@ describe('Messages Routes', () => { id: 2, firstName: 'Jane', lastName: 'Smith', - profileImage: 'jane.jpg' + imageFilename: 'jane.jpg' }, receiver: { id: 1, firstName: 'John', lastName: 'Doe', - profileImage: 'john.jpg' + imageFilename: 'john.jpg' } }); expect(mockMessage.update).not.toHaveBeenCalled(); @@ -304,7 +304,7 @@ describe('Messages Routes', () => { id: 1, firstName: 'John', lastName: 'Doe', - profileImage: 'john.jpg' + imageFilename: 'john.jpg' } }; @@ -330,7 +330,7 @@ describe('Messages Routes', () => { senderId: 1, receiverId: 2, content: 'Hello Jane!', - imagePath: null + imageFilename: null }); }); diff --git a/backend/tests/unit/routes/rentals.test.js b/backend/tests/unit/routes/rentals.test.js index 927138b..2991e50 100644 --- a/backend/tests/unit/routes/rentals.test.js +++ b/backend/tests/unit/routes/rentals.test.js @@ -131,7 +131,7 @@ describe('Rentals Routes', () => { { model: User, as: 'owner', - attributes: ['id', 'username', 'firstName', 'lastName'], + attributes: ['id', 'firstName', 'lastName'], }, ], order: [['createdAt', 'DESC']], @@ -174,7 +174,7 @@ describe('Rentals Routes', () => { { model: User, as: 'renter', - attributes: ['id', 'username', 'firstName', 'lastName'], + attributes: ['id', 'firstName', 'lastName'], }, ], order: [['createdAt', 'DESC']], diff --git a/backend/tests/unit/routes/users.test.js b/backend/tests/unit/routes/users.test.js index 1fbd398..a93899e 100644 --- a/backend/tests/unit/routes/users.test.js +++ b/backend/tests/unit/routes/users.test.js @@ -71,7 +71,7 @@ describe('Users Routes', () => { lastName: 'Doe', email: 'john@example.com', phone: '555-1234', - profileImage: 'profile.jpg', + imageFilename: 'profile.jpg', }; mockUserFindByPk.mockResolvedValue(mockUser); @@ -397,7 +397,7 @@ describe('Users Routes', () => { firstName: 'Jane', lastName: 'Smith', username: 'janesmith', - profileImage: 'jane.jpg', + imageFilename: 'jane.jpg', }; mockUserFindByPk.mockResolvedValue(mockUser); @@ -536,7 +536,7 @@ describe('Users Routes', () => { describe('POST /profile/image', () => { const mockUser = { id: 1, - profileImage: 'old-image.jpg', + imageFilename: 'old-image.jpg', update: jest.fn(), }; @@ -559,7 +559,7 @@ describe('Users Routes', () => { }); expect(fs.unlink).toHaveBeenCalled(); // Old image deleted expect(mockUser.update).toHaveBeenCalledWith({ - profileImage: 'test-profile.jpg' + imageFilename: 'test-profile.jpg' }); }); @@ -617,7 +617,7 @@ describe('Users Routes', () => { const userWithoutImage = { id: 1, - profileImage: null, + imageFilename: null, update: jest.fn().mockResolvedValue() }; mockUserFindByPk.mockResolvedValue(userWithoutImage); @@ -638,7 +638,7 @@ describe('Users Routes', () => { const userWithImage = { id: 1, - profileImage: 'old-image.jpg', + imageFilename: 'old-image.jpg', update: jest.fn().mockResolvedValue() }; mockUserFindByPk.mockResolvedValue(userWithImage); diff --git a/backend/tests/unit/services/conditionCheckService.test.js b/backend/tests/unit/services/conditionCheckService.test.js index 7107643..353e0cf 100644 --- a/backend/tests/unit/services/conditionCheckService.test.js +++ b/backend/tests/unit/services/conditionCheckService.test.js @@ -152,7 +152,7 @@ describe('ConditionCheckService', () => { include: [{ model: User, as: 'submittedByUser', - attributes: ['id', 'username', 'firstName', 'lastName'] + attributes: ['id', 'firstName', 'lastName'] }], order: [['submittedAt', 'ASC']] }); diff --git a/frontend/jest.config.js b/frontend/jest.config.js index aa120ef..3854d69 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -6,7 +6,8 @@ module.exports = { '!src/reportWebVitals.ts', '!src/**/*.d.ts', '!src/setupTests.ts', - '!src/test-polyfills.js' + '!src/test-polyfills.js', + '!src/mocks/**' ], coverageReporters: ['text', 'lcov', 'html'], setupFilesAfterEnv: ['/src/setupTests.ts'], @@ -15,7 +16,7 @@ module.exports = { '/src/**/*.{spec,test}.{js,jsx,ts,tsx}' ], transformIgnorePatterns: [ - 'node_modules/(?!(axios|@stripe)/)' + '/node_modules/(?!(axios|@stripe)/).*' ], moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy' diff --git a/frontend/jest.env.js b/frontend/jest.env.js new file mode 100644 index 0000000..900abaf --- /dev/null +++ b/frontend/jest.env.js @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d6014d..9a9e800 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@types/google.maps": "^3.58.1", + "cross-fetch": "^4.1.0", "dotenv-cli": "^9.0.0", "msw": "^2.11.2" } @@ -6238,6 +6239,16 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12174,6 +12185,52 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8edf65f..89bfaf4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@types/google.maps": "^3.58.1", + "cross-fetch": "^4.1.0", "dotenv-cli": "^9.0.0", "msw": "^2.11.2" } diff --git a/frontend/src/__mocks__/axios.ts b/frontend/src/__mocks__/axios.ts new file mode 100644 index 0000000..57c6145 --- /dev/null +++ b/frontend/src/__mocks__/axios.ts @@ -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[]) => 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'; diff --git a/frontend/src/__tests__/contexts/AuthContext.test.tsx b/frontend/src/__tests__/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..72a42e6 --- /dev/null +++ b/frontend/src/__tests__/contexts/AuthContext.test.tsx @@ -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; +const mockFetchCSRFToken = fetchCSRFToken as jest.MockedFunction; +const mockResetCSRFToken = resetCSRFToken as jest.MockedFunction; + +// Test component that uses the auth context +const TestComponent: React.FC = () => { + const auth = useAuth(); + + return ( +
+
{auth.loading ? 'loading' : 'not-loading'}
+
{auth.user ? auth.user.email : 'no-user'}
+
{auth.user?.isVerified ? 'verified' : 'not-verified'}
+
{auth.showAuthModal ? 'open' : 'closed'}
+
{auth.authModalMode}
+ + + + + + + + +
+ ); +}; + +// Wrapper component for testing +const renderWithAuth = (ui: React.ReactElement = ) => { + return render( + + {ui} + + ); +}; + +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()).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(null); + + const handleLogin = async () => { + try { + await auth.login('test@example.com', 'wrongpassword'); + } catch (err: any) { + setError(err.message); + } + }; + + return ( +
+
{auth.loading ? 'loading' : 'not-loading'}
+
{auth.user ? auth.user.email : 'no-user'}
+
{error || 'no-error'}
+ +
+ ); + }; + + render( + + + + ); + + 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 ( +
+
{auth.loading ? 'loading' : 'not-loading'}
+
{auth.user?.email || 'no-user'}
+
{auth.user?.firstName || 'no-name'}
+ +
+ ); + }; + + renderWithAuth(); + + 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, + }); + }); + }); +}); diff --git a/frontend/src/__tests__/hooks/useAddressAutocomplete.test.ts b/frontend/src/__tests__/hooks/useAddressAutocomplete.test.ts new file mode 100644 index 0000000..6514049 --- /dev/null +++ b/frontend/src/__tests__/hooks/useAddressAutocomplete.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/__tests__/services/api.test.ts b/frontend/src/__tests__/services/api.test.ts new file mode 100644 index 0000000..068e85b --- /dev/null +++ b/frontend/src/__tests__/services/api.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 50e676e..ea2de21 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -437,9 +437,9 @@ const ChatWindow: React.FC = ({ {/* Header */}
- {recipient.profileImage ? ( + {recipient.imageFilename ? ( {`${recipient.firstName} = ({ wordBreak: "break-word", }} > - {message.imagePath && ( + {message.imageFilename && (
Shared image = ({ }} onClick={() => window.open( - getMessageImageUrl(message.imagePath!), + getMessageImageUrl(message.imageFilename!), "_blank" ) } diff --git a/frontend/src/components/CommentThread.tsx b/frontend/src/components/CommentThread.tsx index 07a5f04..33badea 100644 --- a/frontend/src/components/CommentThread.tsx +++ b/frontend/src/components/CommentThread.tsx @@ -212,9 +212,9 @@ const CommentThread: React.FC = ({

{comment.content}

- {comment.images && comment.images.length > 0 && ( + {comment.imageFilenames && comment.imageFilenames.length > 0 && (
- {comment.images.map((image, index) => ( + {comment.imageFilenames.map((image, index) => (
= ({ return (
- {item.images && item.images[0] ? ( + {item.imageFilenames && item.imageFilenames[0] ? ( {item.name} = ({ item, onViewDetails }) return (
- {item.images && item.images[0] ? ( + {item.imageFilenames && item.imageFilenames[0] ? ( {item.name} = ({ itemId }) => { onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)} style={{ cursor: "pointer" }} > - {rental.renter?.profileImage ? ( + {rental.renter?.imageFilename ? ( {`${rental.renter.firstName} = ({ {rental.owner && rental.item && (
- {rental.owner.profileImage ? ( + {rental.owner.imageFilename ? ( {`${rental.owner.firstName} = ({ {rental.renter && rental.item && (
- {rental.renter.profileImage ? ( + {rental.renter.imageFilename ? ( {`${rental.renter.firstName}(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; +}; diff --git a/frontend/src/mocks/server.ts b/frontend/src/mocks/server.ts new file mode 100644 index 0000000..82286f6 --- /dev/null +++ b/frontend/src/mocks/server.ts @@ -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, + }; +}); diff --git a/frontend/src/pages/EditItem.tsx b/frontend/src/pages/EditItem.tsx index a897238..f657a9c 100644 --- a/frontend/src/pages/EditItem.tsx +++ b/frontend/src/pages/EditItem.tsx @@ -162,8 +162,8 @@ const EditItem: React.FC = () => { }); // Set existing images as previews - if (item.images && item.images.length > 0) { - setImagePreviews(item.images); + if (item.imageFilenames && item.imageFilenames.length > 0) { + setImagePreviews(item.imageFilenames); } // Determine which pricing unit to select based on existing data diff --git a/frontend/src/pages/ForumPostDetail.tsx b/frontend/src/pages/ForumPostDetail.tsx index 3970c66..a745a1f 100644 --- a/frontend/src/pages/ForumPostDetail.tsx +++ b/frontend/src/pages/ForumPostDetail.tsx @@ -343,9 +343,9 @@ const ForumPostDetail: React.FC = () => { {post.content}
- {post.images && post.images.length > 0 && ( + {post.imageFilenames && post.imageFilenames.length > 0 && (
- {post.images.map((image, index) => ( + {post.imageFilenames.map((image, index) => (
{
{/* Images */} - {item.images.length > 0 ? ( + {item.imageFilenames.length > 0 ? (
{item.name} { objectFit: "cover", }} /> - {item.images.length > 1 && ( + {item.imageFilenames.length > 1 && (
- {item.images.map((image, index) => ( + {item.imageFilenames.map((image, index) => ( { onClick={() => navigate(`/users/${item.ownerId}`)} style={{ cursor: "pointer" }} > - {item.owner.profileImage ? ( + {item.owner.imageFilename ? ( {`${item.owner.firstName} {
{/* Profile Picture */} - {conversation.partner.profileImage ? ( + {conversation.partner.imageFilename ? ( {`${conversation.partner.firstName} { {allOwnerRentals.map((rental) => (
- {rental.item?.images && rental.item.images[0] && ( + {rental.item?.imageFilenames && rental.item.imageFilenames[0] && ( {rental.item.name} { navigate(`/items/${item.id}`); }} > - {item.images && item.images[0] && ( + {item.imageFilenames && item.imageFilenames[0] && ( {item.name} { state: string; zipCode: string; country: string; - profileImage: string; + imageFilename: string; itemRequestNotificationRadius: number | null; }>({ firstName: "", @@ -52,7 +52,7 @@ const Profile: React.FC = () => { state: "", zipCode: "", country: "", - profileImage: "", + imageFilename: "", itemRequestNotificationRadius: 10, }); const [imageFile, setImageFile] = useState(null); @@ -156,12 +156,12 @@ const Profile: React.FC = () => { state: response.data.state || "", zipCode: response.data.zipCode || "", country: response.data.country || "", - profileImage: response.data.profileImage || "", + imageFilename: response.data.imageFilename || "", itemRequestNotificationRadius: response.data.itemRequestNotificationRadius || 10, }); - if (response.data.profileImage) { - setImagePreview(getImageUrl(response.data.profileImage)); + if (response.data.imageFilename) { + setImagePreview(getImageUrl(response.data.imageFilename)); } } catch (err: any) { setError(err.response?.data?.message || "Failed to fetch profile"); @@ -304,14 +304,14 @@ const Profile: React.FC = () => { // Upload image immediately try { const formData = new FormData(); - formData.append("profileImage", file); + formData.append("imageFilename", file); const response = await userAPI.uploadProfileImage(formData); - // Update the profileImage in formData with the new filename + // Update the imageFilename in formData with the new filename setFormData((prev) => ({ ...prev, - profileImage: response.data.filename, + imageFilename: response.data.filename, })); // Update preview to use the uploaded image URL @@ -322,8 +322,8 @@ const Profile: React.FC = () => { // Reset on error setImageFile(null); setImagePreview( - profileData?.profileImage - ? getImageUrl(profileData.profileImage) + profileData?.imageFilename + ? getImageUrl(profileData.imageFilename) : null ); } @@ -336,8 +336,8 @@ const Profile: React.FC = () => { setSuccess(null); try { - // Don't send profileImage in the update data as it's handled separately - const { profileImage, ...updateData } = formData; + // Don't send imageFilename in the update data as it's handled separately + const { imageFilename, ...updateData } = formData; const response = await userAPI.updateProfile(updateData); setProfileData(response.data); @@ -379,12 +379,12 @@ const Profile: React.FC = () => { state: profileData.state || "", zipCode: profileData.zipCode || "", country: profileData.country || "", - profileImage: profileData.profileImage || "", + imageFilename: profileData.imageFilename || "", itemRequestNotificationRadius: profileData.itemRequestNotificationRadius || 10, }); setImagePreview( - profileData.profileImage ? getImageUrl(profileData.profileImage) : null + profileData.imageFilename ? getImageUrl(profileData.imageFilename) : null ); } }; @@ -774,7 +774,7 @@ const Profile: React.FC = () => { )} {editing && (