Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 56s
1264 lines
53 KiB
TypeScript
1264 lines
53 KiB
TypeScript
// src/routes/user.routes.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import express from 'express';
|
|
import * as bcrypt from 'bcrypt';
|
|
import fs from 'node:fs/promises';
|
|
import {
|
|
createMockUserProfile,
|
|
createMockMasterGroceryItem,
|
|
createMockShoppingList,
|
|
createMockShoppingListItem,
|
|
createMockRecipe,
|
|
createMockNotification,
|
|
createMockDietaryRestriction,
|
|
createMockAppliance,
|
|
createMockUserWithPasswordHash,
|
|
createMockAddress,
|
|
} from '../tests/utils/mockFactories';
|
|
import { Appliance, Notification, DietaryRestriction } from '../types';
|
|
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
import { mockLogger } from '../tests/utils/mockLogger';
|
|
import { logger } from '../services/logger.server';
|
|
|
|
// 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(),
|
|
getShoppingListById: vi.fn(), // Added missing mock
|
|
},
|
|
recipeRepo: {
|
|
deleteRecipe: vi.fn(),
|
|
updateRecipe: vi.fn(),
|
|
},
|
|
addressRepo: {
|
|
getAddressById: vi.fn(),
|
|
upsertAddress: vi.fn(),
|
|
},
|
|
notificationRepo: {
|
|
getNotificationsForUser: vi.fn(),
|
|
markAllNotificationsAsRead: vi.fn(),
|
|
markNotificationAsRead: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock userService
|
|
vi.mock('../services/userService', () => ({
|
|
userService: {
|
|
upsertUserAddress: 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', async () => ({
|
|
// Use async import to avoid hoisting issues with mockLogger
|
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
|
}));
|
|
|
|
// Import the router and other modules AFTER mocks are established
|
|
import userRouter from './user.routes';
|
|
import { userService } from '../services/userService'; // Import for checking calls
|
|
// Import the mocked db module to control its functions in tests
|
|
import * as db from '../services/db/index.db';
|
|
|
|
// 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(),
|
|
),
|
|
}));
|
|
|
|
// Define a reusable matcher for the logger object.
|
|
// This is more specific than `expect.anything()` and ensures a logger-like object is passed.
|
|
const expectLogger = expect.objectContaining({
|
|
info: expect.any(Function),
|
|
error: expect.any(Function),
|
|
});
|
|
describe('User Routes (/api/users)', () => {
|
|
// This test needs to be separate because the code it tests runs on module load.
|
|
describe('Avatar Upload Directory Creation', () => {
|
|
it('should log an error if avatar directory creation fails', async () => {
|
|
// Arrange
|
|
const mkdirError = new Error('EACCES: permission denied');
|
|
// Reset modules to force re-import with a new mock implementation
|
|
vi.resetModules();
|
|
// Set up the mock *before* the module is re-imported
|
|
vi.doMock('node:fs/promises', () => ({
|
|
default: {
|
|
// We only need to mock mkdir for this test.
|
|
mkdir: vi.fn().mockRejectedValue(mkdirError),
|
|
},
|
|
}));
|
|
const { logger } = await import('../services/logger.server');
|
|
|
|
// Act: Dynamically import the router to trigger the top-level fs.mkdir call
|
|
await import('./user.routes');
|
|
|
|
// Assert
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: mkdirError },
|
|
'Failed to create multer storage directories on startup.',
|
|
);
|
|
vi.doUnmock('node:fs/promises'); // Clean up
|
|
});
|
|
});
|
|
|
|
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: { user_id: 'user-123', email: 'test@test.com' },
|
|
});
|
|
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.user_id,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/profile');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] GET /api/users/profile - ERROR`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/watched-items');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] GET /api/users/watched-items - ERROR`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.post('/api/users/watched-items')
|
|
.send({ itemName: 'Test', category: 'Produce' });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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);
|
|
// Check the 'errors' array for the specific validation message.
|
|
expect(response.body.errors[0].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);
|
|
// Check the 'errors' array for the specific validation message.
|
|
expect(response.body.errors[0].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.user_id,
|
|
99,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(dbError);
|
|
const response = await supertest(app).delete(`/api/users/watched-items/99`);
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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.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('should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.getShoppingLists).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/shopping-lists');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] GET /api/users/shopping-lists - ERROR`,
|
|
);
|
|
});
|
|
|
|
it('POST /shopping-lists should create a new list', async () => {
|
|
const mockNewList = createMockShoppingList({
|
|
shopping_list_id: 2,
|
|
user_id: mockUserProfile.user.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);
|
|
// Check the 'errors' array for the specific validation message.
|
|
expect(response.body.errors[0].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 500 on a generic database error during creation', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.post('/api/users/shopping-lists')
|
|
.send({ name: 'Failing List' });
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('DB Connection Failed');
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
// Check the 'errors' array for the specific validation message.
|
|
expect(response.body.errors[0].message).toContain('received NaN');
|
|
});
|
|
|
|
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 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(dbError);
|
|
const response = await supertest(app).delete('/api/users/shopping-lists/1');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
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).toContain('received NaN');
|
|
});
|
|
});
|
|
});
|
|
describe('Shopping List Item Routes', () => {
|
|
describe('POST /shopping-lists/:listId/items (Validation)', () => {
|
|
it('should return 400 if neither masterItemId nor customItemName are provided', async () => {
|
|
const response = await supertest(app).post('/api/users/shopping-lists/1/items').send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toBe(
|
|
'Either masterItemId or customItemName must be provided.',
|
|
);
|
|
});
|
|
|
|
it('should succeed if only masterItemId is provided', async () => {
|
|
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(
|
|
createMockShoppingListItem({}),
|
|
);
|
|
const response = await supertest(app)
|
|
.post('/api/users/shopping-lists/1/items')
|
|
.send({ masterItemId: 123 });
|
|
expect(response.status).toBe(201);
|
|
});
|
|
|
|
it('should succeed if only customItemName is provided', async () => {
|
|
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(
|
|
createMockShoppingListItem({}),
|
|
);
|
|
const response = await supertest(app)
|
|
.post('/api/users/shopping-lists/1/items')
|
|
.send({ customItemName: 'Custom Item' });
|
|
expect(response.status).toBe(201);
|
|
});
|
|
|
|
it('should succeed if both masterItemId and customItemName are provided', async () => {
|
|
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(
|
|
createMockShoppingListItem({}),
|
|
);
|
|
const response = await supertest(app)
|
|
.post('/api/users/shopping-lists/1/items')
|
|
.send({ masterItemId: 123, customItemName: 'Custom Item' });
|
|
expect(response.status).toBe(201);
|
|
});
|
|
});
|
|
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('should return 500 on a generic database error when adding an item', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.addShoppingListItem).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.post('/api/users/shopping-lists/1/items')
|
|
.send({ customItemName: 'Test' });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('should return 500 on a generic database error when updating an item', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/shopping-lists/items/101')
|
|
.send({ is_purchased: true });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 400 if no update fields are provided for an item', async () => {
|
|
const response = await supertest(app).put(`/api/users/shopping-lists/items/101`).send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toContain(
|
|
'At least one field (quantity, is_purchased) must be provided.',
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(dbError);
|
|
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PUT /profile', () => {
|
|
it('should update the user profile successfully', async () => {
|
|
const profileUpdates = { full_name: 'New Name' };
|
|
const updatedProfile = createMockUserProfile({ ...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 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/profile')
|
|
.send({ full_name: 'New Name' });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] PUT /api/users/profile - ERROR`,
|
|
);
|
|
});
|
|
|
|
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 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never);
|
|
vi.mocked(db.userRepo.updateUserPassword).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/profile/password')
|
|
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] PUT /api/users/profile/password - ERROR`,
|
|
);
|
|
});
|
|
|
|
it('should return 400 for a weak password', async () => {
|
|
// Use a password long enough to pass .min(8) but weak enough to fail strength check
|
|
const response = await supertest(app)
|
|
.put('/api/users/profile/password')
|
|
.send({ newPassword: 'password123' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toContain('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).mockRejectedValue(
|
|
new NotFoundError('User not found or password not set.'),
|
|
);
|
|
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.');
|
|
});
|
|
|
|
it('should return 404 if user is an OAuth user without a password', async () => {
|
|
// Simulate an OAuth user who has no password_hash set.
|
|
const userWithoutHash = createMockUserWithPasswordHash({
|
|
...mockUserProfile.user,
|
|
password_hash: null,
|
|
});
|
|
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithoutHash);
|
|
|
|
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.');
|
|
});
|
|
|
|
it('should return 500 on a generic database error', async () => {
|
|
const userWithHash = createMockUserWithPasswordHash({
|
|
...mockUserProfile.user,
|
|
password_hash: 'hashed-password',
|
|
});
|
|
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
|
|
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
|
|
vi.mocked(db.userRepo.deleteUserById).mockRejectedValue(new Error('DB Connection Failed'));
|
|
const response = await supertest(app)
|
|
.delete('/api/users/account')
|
|
.send({ password: 'correct-password' });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: new Error('DB Connection Failed') },
|
|
`[ROUTE] DELETE /api/users/account - ERROR`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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 = createMockUserProfile({
|
|
...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 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.userRepo.updateUserPreferences).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/profile/preferences')
|
|
.send({ darkMode: true });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] PUT /api/users/profile/preferences - ERROR`,
|
|
);
|
|
});
|
|
|
|
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);
|
|
// Zod or Body Parser error
|
|
expect(response.body).toBeDefined();
|
|
});
|
|
});
|
|
|
|
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('GET should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`,
|
|
);
|
|
});
|
|
|
|
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);
|
|
// Check the 'errors' array for the specific validation message.
|
|
expect(response.body.errors[0].message).toContain('received NaN');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('PUT should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/me/dietary-restrictions')
|
|
.send({ restrictionIds: [1] });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('PUT should return 400 if restrictionIds is not an array', async () => {
|
|
const response = await supertest(app)
|
|
.put('/api/users/me/dietary-restrictions')
|
|
.send({ restrictionIds: 'not-an-array' });
|
|
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('GET should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.getUserAppliances).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/me/appliances');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
`[ROUTE] GET /api/users/me/appliances - ERROR`,
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('PUT should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/me/appliances')
|
|
.send({ applianceIds: [1] });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('PUT should return 400 if applianceIds is not an array', async () => {
|
|
const response = await supertest(app)
|
|
.put('/api/users/me/appliances')
|
|
.send({ applianceIds: 'not-an-array' });
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Notification Routes', () => {
|
|
it('GET /notifications should return only unread notifications by default', 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');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(mockNotifications);
|
|
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
|
'user-123',
|
|
10,
|
|
0, // default offset
|
|
false, // default includeRead
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('GET /notifications?includeRead=true should return all notifications', async () => {
|
|
const mockNotifications: Notification[] = [
|
|
createMockNotification({ user_id: 'user-123', content: 'Read', is_read: true }),
|
|
createMockNotification({ user_id: 'user-123', content: 'Unread', is_read: false }),
|
|
];
|
|
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
|
|
|
|
const response = await supertest(app).get('/api/users/notifications?includeRead=true');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(mockNotifications);
|
|
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
|
|
'user-123',
|
|
20, // default limit
|
|
0, // default offset
|
|
true, // includeRead from query param
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('GET /notifications should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.notificationRepo.getNotificationsForUser).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/notifications');
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
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',
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('POST /notifications/mark-all-read should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockRejectedValue(dbError);
|
|
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
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',
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('POST /notifications/:notificationId/mark-read should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.notificationRepo.markNotificationAsRead).mockRejectedValue(dbError);
|
|
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
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).toContain('received NaN');
|
|
});
|
|
});
|
|
|
|
describe('Address Routes', () => {
|
|
it('GET /addresses/:addressId should return the address if it belongs to the user', async () => {
|
|
const appWithUser = createTestApp({
|
|
router: userRouter,
|
|
basePath,
|
|
authenticatedUser: { ...mockUserProfile, address_id: 1 },
|
|
});
|
|
const mockAddress = createMockAddress({ address_id: 1, address_line_1: '123 Main St' });
|
|
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(mockAddress);
|
|
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(mockAddress);
|
|
});
|
|
|
|
it('GET /addresses/:addressId should return 500 on a generic database error', async () => {
|
|
const appWithUser = createTestApp({
|
|
router: userRouter,
|
|
basePath,
|
|
authenticatedUser: { ...mockUserProfile, address_id: 1 },
|
|
});
|
|
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(new Error('DB Error'));
|
|
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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(userService.upsertUserAddress).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
addressData,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('PUT /profile/address should return 500 on a generic service error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(userService.upsertUserAddress).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/profile/address')
|
|
.send({ address_line_1: '123 New St' });
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
it('should return 400 if the address body is empty', async () => {
|
|
const response = await supertest(app).put('/api/users/profile/address').send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toContain(
|
|
'At least one address field must be provided',
|
|
);
|
|
});
|
|
});
|
|
|
|
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.user_id,
|
|
{ avatar_url: expect.any(String) },
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('should return 500 if updating the profile fails after upload', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
|
|
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(500);
|
|
});
|
|
|
|
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 the uploaded file is too large', async () => {
|
|
// This test relies on the `fileSize` limit set in the multer config in user.routes.ts
|
|
const largeFile = Buffer.alloc(2 * 1024 * 1024, 'a'); // 2MB file, assuming limit is smaller
|
|
const dummyImagePath = 'large-avatar.png';
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/users/profile/avatar')
|
|
.attach('avatar', largeFile, dummyImagePath);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toContain('File too large');
|
|
});
|
|
|
|
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 clean up the uploaded file if updating the profile fails', async () => {
|
|
// Spy on the unlink function to ensure it's called on error
|
|
const unlinkSpy = vi.spyOn(fs, 'unlink').mockResolvedValue(undefined);
|
|
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
|
|
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(500);
|
|
// Verify that the cleanup function was called
|
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
|
// The filename is predictable because of the multer config in user.routes.ts
|
|
expect(unlinkSpy).toHaveBeenCalledWith(expect.stringContaining('test-avatar.png'));
|
|
|
|
unlinkSpy.mockRestore();
|
|
});
|
|
|
|
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).toContain('received NaN');
|
|
});
|
|
});
|
|
|
|
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.user_id,
|
|
false,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('DELETE /recipes/:recipeId should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(dbError);
|
|
const response = await supertest(app).delete('/api/users/recipes/1');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
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.user_id,
|
|
updates,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
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('PUT /recipes/:recipeId should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(dbError);
|
|
const response = await supertest(app)
|
|
.put('/api/users/recipes/1')
|
|
.send({ name: 'New Name' });
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('PUT /recipes/:recipeId should return 400 if no update fields are provided', async () => {
|
|
const response = await supertest(app).put('/api/users/recipes/1').send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors[0].message).toBe('No fields provided to update.');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
|
|
const mockList = createMockShoppingList({
|
|
shopping_list_id: 1,
|
|
user_id: mockUserProfile.user.user_id,
|
|
});
|
|
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
|
|
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(mockList);
|
|
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(
|
|
1,
|
|
mockUserProfile.user.user_id,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('GET /shopping-lists/:listId should return 500 on a generic database error', async () => {
|
|
const dbError = new Error('DB Connection Failed');
|
|
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(dbError);
|
|
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
|
expect(response.status).toBe(500);
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
}); // End of Recipe Routes
|
|
});
|
|
});
|