Files
flyer-crawler.projectium.com/src/routes/user.routes.test.ts
Torben Sorensen e156c385a5
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m11s
many fixes resultnig from latest refactoring
2025-12-08 22:12:04 -08:00

593 lines
26 KiB
TypeScript

// src/routes/user.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
// Use * as bcrypt to match the implementation's import style and ensure mocks align.
import * as bcrypt from 'bcrypt';
import userRouter from './user.routes';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe } from '../tests/utils/mockFactories';
import { Appliance, Notification } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
// 1. Mock the Service Layer directly.
// The user.routes.ts file imports from '.../db/index.db'. We need to mock that module.
vi.mock('../services/db/index.db', () => ({
// Repository instances
userRepo: {
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPassword: vi.fn(),
findUserWithPasswordHashById: vi.fn(),
deleteUserById: vi.fn(),
updateUserPreferences: vi.fn(),
},
personalizationRepo: {
getWatchedItems: vi.fn(),
removeWatchedItem: vi.fn(),
addWatchedItem: vi.fn(),
getUserDietaryRestrictions: vi.fn(),
setUserDietaryRestrictions: vi.fn(),
getUserAppliances: vi.fn(),
setUserAppliances: vi.fn(),
},
shoppingRepo: {
getShoppingLists: vi.fn(),
createShoppingList: vi.fn(),
deleteShoppingList: vi.fn(),
addShoppingListItem: vi.fn(),
updateShoppingListItem: vi.fn(),
removeShoppingListItem: vi.fn(),
},
recipeRepo: {
deleteRecipe: vi.fn(),
updateRecipe: vi.fn(),
},
addressRepo: {
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
},
notificationRepo: {
getNotificationsForUser: vi.fn(),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
},
}));
// 2. Mock bcrypt.
// We return an object that satisfies both default and named imports to be safe.
vi.mock('bcrypt', () => {
const hash = vi.fn();
const compare = vi.fn();
return {
default: { hash, compare },
hash,
compare,
};
});
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
// Mock Passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(() => (req: express.Request, res: express.Response, next: express.NextFunction) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
// If a user WAS injected by our test helper, proceed.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
}),
},
// We also need to provide mocks for any other named exports from passport.routes.ts
isAdmin: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) => next()),
optionalAuth: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) => next()),
}));
// Import the mocked db module to control its functions in tests
import * as db from '../services/db/index.db';
// Setup Express App
const createApp = (authenticatedUser?: any) => {
const app = express();
app.use(express.json({ strict: false }));
if (authenticatedUser) {
app.use((req, res, next) => {
req.user = authenticatedUser;
next();
});
}
app.use('/api/users', userRouter);
// Add a generic error handler to catch errors passed via next()
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ message: err.message || 'Internal Server Error' });
});
return app;
};
describe('User Routes (/api/users)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when user is not authenticated', () => {
it('GET /profile should return 401 because the mockAuth middleware is not active for this test block', async () => {
const app = createApp(); // No user injected
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(401);
});
});
describe('when user is authenticated', () => {
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
let app: express.Express;
beforeEach(() => {
app = createApp(mockUserProfile);
});
describe('GET /profile', () => {
it('should return the full user profile', async () => {
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserProfile);
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(mockUserProfile.user_id);
});
it('should return 404 if profile is not found in DB', async () => {
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(undefined);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Profile not found for this user.');
});
it('should return 500 if the database call fails', async () => {
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(500);
});
});
describe('GET /watched-items', () => {
it('should return a list of watched items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(db.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(500);
});
});
describe('POST /watched-items', () => {
it('should add an item to the watchlist and return the new item', async () => {
const newItem = { itemName: 'Organic Bananas', category: 'Produce' };
const mockAddedItem = createMockMasterGroceryItem({ master_grocery_item_id: 99, name: 'Organic Bananas', category_name: 'Produce' });
vi.mocked(db.personalizationRepo.addWatchedItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
.post('/api/users/watched-items')
.send(newItem);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Failing Item', category: 'Errors' });
expect(response.status).toBe(500);
});
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(new ForeignKeyConstraintError('Category not found'));
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Invalid' });
expect(response.status).toBe(400);
});
describe('DELETE /watched-items/:masterItemId', () => {
it('should remove an item from the watchlist', async () => {
vi.mocked(db.personalizationRepo.removeWatchedItem).mockResolvedValue(undefined);
const response = await supertest(app).delete(`/api/users/watched-items/99`);
expect(response.status).toBe(204);
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user_id, 99);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).delete('/api/users/watched-items/99');
expect(response.status).toBe(500);
});
});
describe('Shopping List Routes', () => {
it('GET /shopping-lists should return all shopping lists for the user', async () => {
const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id })];
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
const response = await supertest(app).get('/api/users/shopping-lists');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLists);
});
it('POST /shopping-lists should create a new list', async () => {
const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies' });
vi.mocked(db.shoppingRepo.createShoppingList).mockResolvedValue(mockNewList);
const response = await supertest(app)
.post('/api/users/shopping-lists')
.send({ name: 'Party Supplies' });
expect(response.status).toBe(201);
expect(response.body).toEqual(mockNewList);
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
it('DELETE /shopping-lists/:listId should delete a list', async () => {
vi.mocked(db.shoppingRepo.deleteShoppingList).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/1');
expect(response.status).toBe(204);
});
it('should return 400 for an invalid listId on DELETE', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid list ID.');
});
it('should return 404 if list to delete is not found', async () => {
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new Error('not found'));
const response = await supertest(app).delete('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
});
});
describe('Shopping List Item Routes', () => {
it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
const listId = 1;
const itemData = { customItemName: 'Paper Towels' };
const mockAddedItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: listId, ...itemData });
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
.post(`/api/users/shopping-lists/${listId}/items`)
.send(itemData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem);
});
it('PUT /shopping-lists/items/:itemId should update an item', async () => {
const itemId = 101;
const updates = { is_purchased: true, quantity: 2 };
const mockUpdatedItem = createMockShoppingListItem({ shopping_list_item_id: itemId, shopping_list_id: 1, ...updates });
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
const response = await supertest(app)
.put(`/api/users/shopping-lists/items/${itemId}`)
.send(updates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedItem);
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
it('DELETE /shopping-lists/items/:itemId should delete an item', async () => {
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
expect(response.status).toBe(204);
});
it('should return 400 for an invalid itemId on DELETE', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/items/abc');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid item ID.');
});
it('should return 404 if list to delete is not found', async () => {
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new Error('not found'));
const response = await supertest(app).delete('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
});
});
describe('PUT /profile', () => {
it('should update the user profile successfully', async () => {
const profileUpdates = { full_name: 'New Name' };
const updatedProfile = { ...mockUserProfile, ...profileUpdates };
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile')
.send(profileUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
});
it('should return 400 if no update fields are provided', async () => {
const response = await supertest(app)
.put('/api/users/profile')
.send({});
expect(response.status).toBe(400);
expect(response.body.message).toBe('At least one field to update must be provided.');
});
});
describe('PUT /profile/password', () => {
it('should update the password successfully with a strong password', async () => {
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never);
vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined);
const response = await supertest(app)
.put('/api/users/profile/password')
.send({ newPassword: 'a-Very-Strong-Password-456!' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Password updated successfully.');
});
it('should return 400 for a weak password', async () => {
const response = await supertest(app)
.put('/api/users/profile/password')
.send({ newPassword: 'weak' });
expect(response.status).toBe(400);
expect(response.body.message).toContain('New password is too weak.');
});
});
describe('DELETE /account', () => {
it('should delete the account with the correct password', async () => {
const userWithHash = {
...mockUserProfile.user,
password_hash: 'hashed-password',
failed_login_attempts: 0, // Add missing properties
last_failed_login: null
};
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'correct-password' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Account deleted successfully.');
});
it('should return 403 for an incorrect password', async () => {
const userWithHash = {
...mockUserProfile.user,
password_hash: 'hashed-password',
failed_login_attempts: 0,
last_failed_login: null
};
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'wrong-password' });
expect(response.status).toBe(403);
expect(response.body.message).toBe('Incorrect password.');
});
});
describe('User Preferences and Personalization', () => {
describe('PUT /profile/preferences', () => {
it('should update user preferences successfully', async () => {
const preferencesUpdate = { darkMode: true, unitSystem: 'metric' as const };
const updatedProfile = {
...mockUserProfile,
preferences: { ...mockUserProfile.preferences, ...preferencesUpdate }
};
vi.mocked(db.userRepo.updateUserPreferences).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile/preferences')
.send(preferencesUpdate);
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
});
it('should return 400 if the request body is not a valid object', async () => {
const response = await supertest(app)
.put('/api/users/profile/preferences')
.set('Content-Type', 'application/json')
.send('"not-an-object"');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid preferences format. Body must be a JSON object.');
});
});
describe('GET and PUT /users/me/dietary-restrictions', () => {
it('GET should return a list of restriction IDs', async () => {
const mockRestrictions = [{ dietary_restriction_id: 1, name: 'Gluten-Free', type: 'diet' as const }];
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
});
it('PUT should successfully set the restrictions', async () => {
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockResolvedValue(undefined);
const restrictionIds = [1, 3, 5];
const response = await supertest(app)
.put('/api/users/me/dietary-restrictions')
.send({ restrictionIds });
expect(response.status).toBe(204);
});
});
describe('GET and PUT /users/me/appliances', () => {
it('GET should return a list of appliance IDs', async () => {
const mockAppliances: Appliance[] = [{ appliance_id: 2, name: 'Air Fryer' }];
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
});
it('PUT should successfully set the appliances', async () => {
vi.mocked(db.personalizationRepo.setUserAppliances).mockResolvedValue([]);
const applianceIds = [2, 4, 6];
const response = await supertest(app).put('/api/users/me/appliances').send({ applianceIds });
expect(response.status).toBe(204);
});
});
});
describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => {
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '' }];
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith('user-123', 10, 0);
});
it('POST /notifications/mark-all-read should return 204', async () => {
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockResolvedValue(undefined);
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
expect(response.status).toBe(204);
expect(db.notificationRepo.markAllNotificationsAsRead).toHaveBeenCalledWith('user-123');
});
it('POST /notifications/:notificationId/mark-read should return 204', async () => {
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue({} as any);
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
expect(response.status).toBe(204);
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
});
it('should return 400 for an invalid notificationId', async () => {
const response = await supertest(app).post('/api/users/notifications/abc/mark-read');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid notification ID.');
});
});
describe('Address Routes', () => {
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
const appWithDifferentUser = createApp({ ...mockUserProfile, address_id: 999 });
const response = await supertest(appWithDifferentUser).get('/api/users/addresses/1');
expect(response.status).toBe(403);
});
it('GET /addresses/:addressId should return 404 if address not found', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 });
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(undefined);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(404);
});
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: null }); // User has no address yet
const addressData = { address_line_1: '123 New St' };
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({} as any);
const response = await supertest(appWithUser)
.put('/api/users/profile/address')
.send(addressData);
expect(response.status).toBe(200);
expect(db.addressRepo.upsertAddress).toHaveBeenCalledWith({ ...addressData, address_id: undefined });
// Verify that the user's profile was updated to link the new address
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith('user-123', { address_id: 5 });
});
});
describe('POST /profile/avatar', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = { ...mockUserProfile, avatar_url: '/uploads/avatars/new-avatar.png' };
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUpdatedProfile);
// Create a dummy file path for supertest to attach
const dummyImagePath = 'test-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('/uploads/avatars/');
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user_id, { avatar_url: expect.any(String) });
});
it('should return 400 if a non-image file is uploaded', async () => {
const dummyTextPath = 'document.txt';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', Buffer.from('this is not an image'), dummyTextPath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Only image files are allowed!');
});
});
describe('Recipe Routes', () => {
it('DELETE /recipes/:recipeId should delete a user\'s own recipe', async () => {
vi.mocked(db.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/recipes/1');
expect(response.status).toBe(204);
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(1, mockUserProfile.user_id, false);
});
it('PUT /recipes/:recipeId should update a user\'s own recipe', async () => {
const updates = { description: 'A new delicious description.' };
const mockUpdatedRecipe = { ...createMockRecipe(), ...updates };
vi.mocked(db.recipeRepo.updateRecipe).mockResolvedValue(mockUpdatedRecipe);
const response = await supertest(app)
.put('/api/users/recipes/1')
.send(updates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(1, mockUserProfile.user_id, updates);
});
it('PUT /recipes/:recipeId should return 404 if recipe not found', async () => {
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new Error('not found'));
const response = await supertest(app).put('/api/users/recipes/999').send({ name: 'New Name' });
expect(response.status).toBe(404);
});
it('PUT /recipes/:recipeId should return 400 if no update fields are provided', async () => {
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new Error('No fields provided'));
const response = await supertest(app).put('/api/users/recipes/1').send({});
expect(response.status).toBe(400);
});
});
});
});