Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
- Refactored AIService to integrate with the latest GoogleGenAI SDK, updating the generateContent method signature and response handling. - Adjusted error handling and logging for improved clarity and consistency. - Enhanced mock implementations in tests to align with the new SDK structure. refactor: Modify Admin DB service to use Profile type - Updated AdminRepository to replace User type with Profile in relevant methods. - Enhanced test cases to utilize mock factories for creating Profile and AdminUserView objects. fix: Improve error handling in BudgetRepository - Implemented type-safe checks for PostgreSQL error codes to enhance error handling in createBudget method. test: Refactor Deals DB tests for type safety - Updated DealsRepository tests to use Pool type for mock instances, ensuring type safety. chore: Add new mock factories for testing - Introduced mock factories for UserWithPasswordHash, Profile, WatchedItemDeal, LeaderboardUser, and UnmatchedFlyerItem to streamline test data creation. style: Clean up queue service tests - Refactored queue service tests to improve readability and maintainability, including better handling of mock worker instances. docs: Update types to include UserWithPasswordHash - Added UserWithPasswordHash interface to types for better clarity on user authentication data structure. chore: Remove deprecated Google AI SDK references - Updated code and documentation to reflect the migration to the new Google Generative AI SDK, removing references to the deprecated SDK.
626 lines
29 KiB
TypeScript
626 lines
29 KiB
TypeScript
// src/routes/user.routes.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import express 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, createMockNotification, createMockDietaryRestriction, createMockAppliance, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
|
|
import { Appliance, Notification, DietaryRestriction } from '../types';
|
|
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
|
|
// 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';
|
|
|
|
describe('User Routes (/api/users)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
const basePath = '/api/users';
|
|
|
|
describe('when user is not authenticated', () => {
|
|
it('GET /profile should return 401', async () => {
|
|
const app = createTestApp({ router: userRouter, basePath }); // No user injected
|
|
const response = await supertest(app).get('/api/users/profile');
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('when user is authenticated', () => {
|
|
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
|
|
const app = createTestApp({ router: userRouter, basePath, authenticatedUser: mockUserProfile });
|
|
|
|
beforeEach(() => {
|
|
// All tests in this block will use the authenticated app
|
|
});
|
|
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).mockRejectedValue(new NotFoundError('Profile not found for this user.'));
|
|
const response = await supertest(app).get('/api/users/profile');
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.message).toContain('Profile not found');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('POST /watched-items (Validation)', () => {
|
|
it('should return 400 if itemName is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/users/watched-items')
|
|
.send({ category: 'Produce' });
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe("Field 'itemName' is required.");
|
|
});
|
|
|
|
it('should return 400 if category is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/users/watched-items')
|
|
.send({ itemName: 'Apples' });
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe("Field 'category' is required.");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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 name is missing', async () => {
|
|
const response = await supertest(app).post('/api/users/shopping-lists').send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe("Field 'name' is required.");
|
|
});
|
|
|
|
it('should return 400 on foreign key constraint error', 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('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 ID for parameter 'listId'. Must be a number.");
|
|
});
|
|
|
|
describe('DELETE /shopping-lists/:listId', () => {
|
|
it('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 404 if list to delete is not found', async () => {
|
|
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new Error('not found'));
|
|
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new NotFoundError('not found'));
|
|
const response = await supertest(app).delete('/api/users/shopping-lists/999');
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('should return 400 for an invalid listId', async () => {
|
|
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'listId'. Must be a number.");
|
|
});
|
|
});
|
|
});
|
|
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('should return 400 on foreign key error when adding an item', async () => {
|
|
vi.mocked(db.shoppingRepo.addShoppingListItem).mockRejectedValue(new ForeignKeyConstraintError('List not found'));
|
|
const response = await supertest(app).post('/api/users/shopping-lists/999/items').send({ customItemName: 'Test' });
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
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 404 if item to update is not found', async () => {
|
|
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(new NotFoundError('not found'));
|
|
const response = await supertest(app).put('/api/users/shopping-lists/items/999').send({ is_purchased: true });
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
describe('DELETE /shopping-lists/items/:itemId', () => {
|
|
it('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 404 if item to delete is not found', async () => {
|
|
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(new NotFoundError('not found'));
|
|
const response = await supertest(app).delete('/api/users/shopping-lists/items/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 the body is empty', async () => {
|
|
const response = await supertest(app)
|
|
.put('/api/users/profile')
|
|
.send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].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 = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
|
|
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 = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
|
|
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.');
|
|
});
|
|
|
|
it('should return 404 if the user to delete is not found', async () => {
|
|
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(undefined);
|
|
const response = await supertest(app)
|
|
.delete('/api/users/account')
|
|
.send({ password: 'any-password' });
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.message).toBe('User not found or password not set.');
|
|
});
|
|
});
|
|
|
|
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: DietaryRestriction[] = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
|
|
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('should return 400 for an invalid masterItemId', async () => {
|
|
const response = await supertest(app).delete('/api/users/watched-items/abc');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe("Invalid ID for parameter 'masterItemId'. Must be a number.");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('PUT should return 400 on foreign key constraint error', async () => {
|
|
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(new ForeignKeyConstraintError('Invalid restriction ID'));
|
|
const response = await supertest(app)
|
|
.put('/api/users/me/dietary-restrictions')
|
|
.send({ restrictionIds: [999] }); // Invalid ID
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('GET and PUT /users/me/appliances', () => {
|
|
it('GET should return a list of appliance IDs', async () => {
|
|
const mockAppliances: Appliance[] = [createMockAppliance({ 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);
|
|
});
|
|
|
|
it('PUT should return 400 on foreign key constraint error', async () => {
|
|
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(new ForeignKeyConstraintError('Invalid appliance ID'));
|
|
const response = await supertest(app)
|
|
.put('/api/users/me/appliances')
|
|
.send({ applianceIds: [999] }); // Invalid ID
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe('Invalid appliance ID');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Notification Routes', () => {
|
|
it('GET /notifications should return notifications for the user', async () => {
|
|
const mockNotifications: Notification[] = [createMockNotification({ user_id: 'user-123', content: 'Test' })];
|
|
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 () => {
|
|
// Fix: Return a mock notification object to match the function's signature.
|
|
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue(createMockNotification({ notification_id: 1, user_id: 'user-123' }));
|
|
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').send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'notificationId'. Must be a number.");
|
|
});
|
|
});
|
|
|
|
describe('GET /addresses/:addressId', () => {
|
|
it('should return 400 for a non-numeric address ID', async () => {
|
|
const response = await supertest(app).get('/api/users/addresses/abc'); // This was a duplicate, fixed.
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('Address Routes', () => {
|
|
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
|
|
const appWithDifferentUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...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 = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 1 } });
|
|
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(new NotFoundError('Address not found.'));
|
|
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.message).toBe('Address not found.');
|
|
});
|
|
|
|
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
|
|
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...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({ ...mockUserProfile, address_id: 5 });
|
|
|
|
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!');
|
|
});
|
|
|
|
it('should return 400 if no file is uploaded', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/users/profile/avatar'); // No .attach() call
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe('No avatar file uploaded.');
|
|
});
|
|
|
|
it('should return 400 for a non-numeric address ID', async () => {
|
|
const response = await supertest(app).get('/api/users/addresses/abc');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'addressId'. Must be a number.");
|
|
});
|
|
});
|
|
|
|
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({ recipe_id: 1 }), ...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 NotFoundError('not found'));
|
|
const response = await supertest(app).put('/api/users/recipes/999').send({ name: 'New Name' });
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
|
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(new NotFoundError('Shopping list not found'));
|
|
const response = await supertest(app).get('/api/users/shopping-lists/999');
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.message).toBe('Shopping list not found');
|
|
});
|
|
}); // End of Recipe Routes
|
|
});
|
|
}); |