moar unit test !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m46s

This commit is contained in:
2025-12-07 14:29:37 -08:00
parent eec0967c94
commit ef07417978
13 changed files with 1393 additions and 220 deletions

View File

@@ -14,7 +14,7 @@ import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import type { Queue } from 'bullmq';
import { backgroundJobService } from '../services/backgroundJobService.ts';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker } from '../services/queueService.server'; // Import your queues
const router = Router();

View File

@@ -260,20 +260,20 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
// 2. Prepare flyer data for insertion
const flyerData = {
file_name: originalFileName,
image_url: req.file.filename, // Store only the filename
image_url: `/flyer-images/${req.file.filename}`, // Store the full URL path
icon_url: iconUrl,
checksum: checksum,
// Use normalized store name (fallback applied above).
store_name: storeName,
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address,
valid_from: extractedData.valid_from ?? null,
valid_to: extractedData.valid_to ?? null,
store_address: extractedData.store_address ?? null,
item_count: 0, // Set default to 0; the trigger will update it.
uploaded_by: user?.user_id, // Associate with user if logged in
};
// 3. Create flyer and its items in a transaction
const newFlyer = await db.createFlyerAndItems(flyerData, itemsArray);
const { flyer: newFlyer, items: newItems } = await db.createFlyerAndItems(flyerData, itemsArray);
logger.info(`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id})`);
@@ -394,7 +394,7 @@ router.post(
'/rescan-area',
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'),
async (req, res) => {
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
@@ -416,8 +416,7 @@ router.post(
res.status(200).json(result);
} catch (error) {
logger.error('Error in /api/ai/rescan-area endpoint:', { error });
res.status(500).json({ message: (error as Error).message || 'An unexpected error occurred during rescan.' });
next(error);
}
}
);

View File

@@ -127,25 +127,25 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
const payload = { user_id: typedUser.user_id, email: typedUser.email };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = crypto.randomBytes(64).toString('hex');
db.saveRefreshToken(typedUser.user_id, refreshToken).then(() => {
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined
};
try {
const refreshToken = crypto.randomBytes(64).toString('hex');
await db.saveRefreshToken(typedUser.user_id, refreshToken);
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined
};
res.cookie('refreshToken', refreshToken, cookieOptions);
// The user object in the response should match the User type.
const userResponse = { user_id: typedUser.user_id, email: typedUser.email };
res.cookie('refreshToken', refreshToken, cookieOptions);
const userResponse = { user_id: typedUser.user_id, email: typedUser.email };
return res.json({ user: userResponse, token: accessToken });
}).catch(tokenErr => {
logger.error('Failed to save refresh token during login:', { error: tokenErr });
return next(tokenErr);
});
return res.json({ user: userResponse, token: accessToken });
} catch (tokenErr) {
logger.error('Failed to save refresh token during login:', { error: tokenErr });
return next(tokenErr);
}
})(req, res, next);
});

View File

@@ -6,6 +6,9 @@ import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
// --- Public Routes ---
/**
* GET /api/achievements - Get the master list of all available achievements.
@@ -38,6 +41,8 @@ router.get('/leaderboard', async (req, res, next: NextFunction) => {
}
});
// --- Authenticated User Routes ---
/**
* GET /api/achievements/me - Get all achievements for the authenticated user.
* This is a protected endpoint.
@@ -57,14 +62,17 @@ router.get(
}
);
// --- Admin-Only Routes ---
// Apply authentication and admin-check middleware to the entire admin sub-router.
adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), isAdmin);
/**
* POST /api/achievements/award - Manually award an achievement to a user.
* This is an admin-only endpoint.
*/
router.post(
adminGamificationRouter.post(
'/award',
passport.authenticate('jwt', { session: false }),
isAdmin,
async (req, res, next: NextFunction) => {
const { userId, achievementName } = req.body;
@@ -82,4 +90,7 @@ router.post(
}
);
// Mount the admin sub-router onto the main gamification router.
router.use(adminGamificationRouter);
export default router;

View File

@@ -7,7 +7,8 @@ import * as connectionDb from '../services/db/connection.db';
import * as flyerDb from '../services/db/flyer.db';
import * as recipeDb from '../services/db/recipe.db';
import * as adminDb from '../services/db/admin.db';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockRecipe } from '../tests/utils/mockFactories';
import * as personalizationDb from '../services/db/personalization.db';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockRecipe, createMockRecipeComment, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
// 1. Mock the Service Layer directly.
// This decouples the route tests from the SQL implementation details.
@@ -17,7 +18,6 @@ vi.mock('../services/db/connection.db', () => ({
}));
vi.mock('../services/db/flyer.db', () => ({
getFlyers: vi.fn(),
getAllMasterItems: vi.fn(),
getFlyerItems: vi.fn(),
getFlyerItemsForFlyers: vi.fn(),
countFlyerItemsForFlyers: vi.fn(),
@@ -28,6 +28,11 @@ vi.mock('../services/db/recipe.db', () => ({
findRecipesByIngredientAndTag: vi.fn(),
getRecipeComments: vi.fn(),
}));
vi.mock('../services/db/personalization.db', () => ({
getAllMasterItems: vi.fn(),
getDietaryRestrictions: vi.fn(),
getAppliances: vi.fn(),
}));
vi.mock('../services/db/admin.db', () => ({
getMostFrequentSaleItems: vi.fn(),
}));
@@ -147,6 +152,16 @@ describe('Public Routes (/api)', () => {
expect(response.body).toEqual(mockFlyers);
});
it('should pass limit and offset query parameters to the db function', async () => {
const mockFlyers = [createMockFlyer({ flyer_id: 1 })];
vi.mocked(flyerDb.getFlyers).mockResolvedValue(mockFlyers);
await supertest(app).get('/api/flyers?limit=15&offset=30');
expect(flyerDb.getFlyers).toHaveBeenCalledTimes(1);
expect(flyerDb.getFlyers).toHaveBeenCalledWith(15, 30);
});
it('should handle database errors gracefully', async () => {
vi.mocked(flyerDb.getFlyers).mockRejectedValue(new Error('DB Error'));
@@ -160,7 +175,7 @@ describe('Public Routes (/api)', () => {
describe('GET /master-items', () => {
it('should return a list of master items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(flyerDb.getAllMasterItems).mockResolvedValue(mockItems);
vi.mocked(personalizationDb.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/master-items');
@@ -287,4 +302,41 @@ describe('Public Routes (/api)', () => {
expect(response.body.message).toBe('Query parameter "days" must be an integer between 1 and 365.');
});
});
describe('GET /recipes/:recipeId/comments', () => {
it('should return comments for a specific recipe', async () => {
const mockComments = [createMockRecipeComment({ recipe_id: 1, content: 'Great recipe!' })];
vi.mocked(recipeDb.getRecipeComments).mockResolvedValue(mockComments);
const response = await supertest(app).get('/api/recipes/1/comments');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockComments);
expect(recipeDb.getRecipeComments).toHaveBeenCalledWith(1);
});
});
describe('GET /dietary-restrictions', () => {
it('should return a list of all dietary restrictions', async () => {
const mockRestrictions = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
vi.mocked(personalizationDb.getDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
});
});
describe('GET /appliances', () => {
it('should return a list of all appliances', async () => {
const mockAppliances = [createMockAppliance({ name: 'Air Fryer' })];
vi.mocked(personalizationDb.getAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
});
});
});

View File

@@ -57,9 +57,12 @@ router.get('/health/db-pool', (req: Request, res: Response) => {
// --- Public Data Routes ---
router.get('/flyers', async (req, res, next) => {
router.get('/flyers', async (req, res, next: NextFunction) => {
try {
const flyers = await db.getFlyers();
// Add pagination support to the flyers endpoint.
const limit = parseInt(req.query.limit as string, 10) || 20;
const offset = parseInt(req.query.offset as string, 10) || 0;
const flyers = await db.getFlyers(limit, offset);
res.json(flyers);
} catch (error) {
logger.error('Error fetching flyers in /api/flyers:', { error });
@@ -129,58 +132,82 @@ router.get('/recipes/by-sale-percentage', async (req, res, next: NextFunction) =
}
});
router.get('/recipes/by-sale-ingredients', async (req, res) => {
const minIngredientsStr = req.query.minIngredients as string || '3';
const minIngredients = parseInt(minIngredientsStr, 10);
router.get('/recipes/by-sale-ingredients', async (req, res, next: NextFunction) => {
try {
const minIngredientsStr = req.query.minIngredients as string || '3';
const minIngredients = parseInt(minIngredientsStr, 10);
if (isNaN(minIngredients) || minIngredients < 1) {
return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' });
if (isNaN(minIngredients) || minIngredients < 1) {
return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' });
}
const recipes = await db.getRecipesByMinSaleIngredients(minIngredients);
res.json(recipes);
} catch (error) {
next(error);
}
const recipes = await db.getRecipesByMinSaleIngredients(minIngredients);
res.json(recipes);
});
router.get('/recipes/by-ingredient-and-tag', async (req, res) => {
const { ingredient, tag } = req.query;
if (!ingredient || !tag) {
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
router.get('/recipes/by-ingredient-and-tag', async (req, res, next: NextFunction) => {
try {
const { ingredient, tag } = req.query;
if (!ingredient || !tag) {
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
}
const recipes = await db.findRecipesByIngredientAndTag(ingredient as string, tag as string);
res.json(recipes);
} catch (error) {
next(error);
}
const recipes = await db.findRecipesByIngredientAndTag(ingredient as string, tag as string);
res.json(recipes);
});
router.get('/stats/most-frequent-sales', async (req, res) => {
const daysStr = req.query.days as string || '30';
const limitStr = req.query.limit as string || '10';
router.get('/stats/most-frequent-sales', async (req, res, next: NextFunction) => {
try {
const daysStr = req.query.days as string || '30';
const limitStr = req.query.limit as string || '10';
const days = parseInt(daysStr, 10);
const limit = parseInt(limitStr, 10);
const days = parseInt(daysStr, 10);
const limit = parseInt(limitStr, 10);
if (isNaN(days) || days < 1 || days > 365) {
return res.status(400).json({ message: 'Query parameter "days" must be an integer between 1 and 365.' });
if (isNaN(days) || days < 1 || days > 365) {
return res.status(400).json({ message: 'Query parameter "days" must be an integer between 1 and 365.' });
}
if (isNaN(limit) || limit < 1 || limit > 50) {
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
}
const items = await db.getMostFrequentSaleItems(days, limit);
res.json(items);
} catch (error) {
next(error);
}
if (isNaN(limit) || limit < 1 || limit > 50) {
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
});
router.get('/recipes/:recipeId/comments', async (req, res, next: NextFunction) => {
try {
const recipeId = parseInt(req.params.recipeId, 10);
const comments = await db.getRecipeComments(recipeId);
res.json(comments);
} catch (error) {
next(error);
}
const items = await db.getMostFrequentSaleItems(days, limit);
res.json(items);
});
router.get('/recipes/:recipeId/comments', async (req, res) => {
const recipeId = parseInt(req.params.recipeId, 10);
const comments = await db.getRecipeComments(recipeId);
res.json(comments);
router.get('/dietary-restrictions', async (req, res, next: NextFunction) => {
try {
const restrictions = await db.getDietaryRestrictions();
res.json(restrictions);
} catch (error) {
next(error);
}
});
router.get('/dietary-restrictions', async (req, res) => {
const restrictions = await db.getDietaryRestrictions();
res.json(restrictions);
});
router.get('/appliances', async (req, res) => {
const appliances = await db.getAppliances();
res.json(appliances);
router.get('/appliances', async (req, res, next: NextFunction) => {
try {
const appliances = await db.getAppliances();
res.json(appliances);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,6 +1,6 @@
// src/routes/user.routes.ts
import express, { Request, Response } from 'express';
import passport from './passport.routes';
import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes.ts';
import multer from 'multer';
import path from 'path';
import fs from 'node:fs/promises';
@@ -16,60 +16,64 @@ const router = express.Router();
// Any request to a /api/users/* endpoint will now require a valid JWT.
router.use(passport.authenticate('jwt', { session: false }));
/**
* A simple validation middleware to check for required fields in the request body.
* @param requiredFields An array of field names that must be present.
*/
const validateBody = (requiredFields: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
for (const field of requiredFields) {
if (!req.body[field]) {
return res.status(400).json({ message: `Field '${field}' is required.` });
}
}
next();
};
};
// --- Multer Configuration for Avatar Uploads ---
// Ensure the directory for avatar uploads exists.
const avatarUploadDir = path.join(process.cwd(), 'public', 'uploads', 'avatars');
fs.mkdir(avatarUploadDir, { recursive: true }).catch(err => {
logger.error('Failed to create avatar upload directory:', err);
});
// Define multer storage configuration. The `req.user` object will be available
// here because the passport middleware runs before this route handler.
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarUploadDir),
filename: (req, file, cb) => {
const user = req.user as User;
const uniqueSuffix = `${user.user_id}-${Date.now()}${path.extname(file.originalname)}`;
cb(null, uniqueSuffix);
},
});
const avatarUpload = multer({
storage: avatarStorage,
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
// Reject the file with a specific error
cb(new Error('Only image files are allowed!'));
}
},
});
/**
* POST /api/users/profile/avatar - Upload a new avatar for the authenticated user.
*/
router.post(
'/profile/avatar',
async (req, res, next) => {
// Initialize multer inside the route handler where req.user is available.
// This prevents the "Cannot read properties of undefined" error during test setup.
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarUploadDir),
filename: (req, file, cb) => {
const user = req.user as User;
// This code now runs safely because passport has already populated req.user.
const uniqueSuffix = `${user.user_id}-${Date.now()}${path.extname(file.originalname)}`;
cb(null, uniqueSuffix);
},
});
const uploadMiddleware = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed!'));
}
},
});
const upload = uploadMiddleware.single('avatar');
// Manually invoke the multer middleware.
upload(req, res, async (err: unknown) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ message: err.message });
} else if (err) {
return res.status(400).json({ message: (err as Error).message });
}
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const user = req.user as User;
// Construct the public URL for the uploaded file
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
// Update the user's profile in the database with the new URL
const updatedProfile = await db.updateUserProfile(user.user_id, { avatar_url: avatarUrl });
res.json(updatedProfile);
});
avatarUpload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const user = req.user as User;
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const updatedProfile = await db.updateUserProfile(user.user_id, { avatar_url: avatarUrl });
res.json(updatedProfile);
}
);
@@ -79,7 +83,6 @@ router.post(
*/
router.get(
'/notifications',
passport.authenticate('jwt', { session: false }),
async (req: Request, res: Response) => {
const user = req.user as User;
const limit = parseInt(req.query.limit as string, 10) || 20;
@@ -95,7 +98,6 @@ router.get(
*/
router.post(
'/notifications/mark-all-read',
passport.authenticate('jwt', { session: false }),
async (req: Request, res: Response) => {
const user = req.user as User;
await db.markAllNotificationsAsRead(user.user_id);
@@ -108,7 +110,6 @@ router.post(
*/
router.post(
'/notifications/:notificationId/mark-read',
passport.authenticate('jwt', { session: false }),
async (req: Request, res: Response) => {
const user = req.user as User;
const notificationId = parseInt(req.params.notificationId, 10);
@@ -125,7 +126,7 @@ router.post(
/**
* GET /api/users/profile - Get the full profile for the authenticated user.
*/
router.get('/profile', async (req, res, next) => {
router.get('/profile', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/profile - ENTER`);
const user = req.user as UserProfile;
try {
@@ -145,7 +146,7 @@ router.get('/profile', async (req, res, next) => {
/**
* PUT /api/users/profile - Update the user's profile information.
*/
router.put('/profile', async (req, res, next) => {
router.put('/profile', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
const user = req.user as UserProfile;
const { full_name, avatar_url } = req.body;
@@ -166,15 +167,11 @@ router.put('/profile', async (req, res, next) => {
/**
* PUT /api/users/profile/password - Update the user's password.
*/
router.put('/profile/password', async (req, res, next) => {
router.put('/profile/password', validateBody(['newPassword']), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
const user = req.user as UserProfile;
const { newPassword } = req.body;
if (!newPassword) {
return res.status(400).json({ message: 'New password is required.' });
}
const MIN_PASSWORD_SCORE = 3;
const strength = zxcvbn(newPassword);
if (strength.score < MIN_PASSWORD_SCORE) {
@@ -196,15 +193,11 @@ router.put('/profile/password', async (req, res, next) => {
/**
* DELETE /api/users/account - Delete the user's own account.
*/
router.delete('/account', async (req, res, next) => {
router.delete('/account', validateBody(['password']), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
const user = req.user as UserProfile;
const { password } = req.body;
if (!password) {
return res.status(400).json({ message: 'Password is required to confirm account deletion.' });
}
try {
const userWithHash = await db.findUserWithPasswordHashById(user.user_id);
if (!userWithHash || !userWithHash.password_hash) {
@@ -227,7 +220,7 @@ router.delete('/account', async (req, res, next) => {
/**
* GET /api/users/watched-items - Get all watched items for the authenticated user.
*/
router.get('/watched-items', async (req, res, next) => {
router.get('/watched-items', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
const user = req.user as UserProfile;
try {
@@ -242,7 +235,7 @@ router.get('/watched-items', async (req, res, next) => {
/**
* POST /api/users/watched-items - Add a new item to the user's watchlist.
*/
router.post('/watched-items', async (req, res, next) => {
router.post('/watched-items', validateBody(['itemName', 'category']), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
const user = req.user as UserProfile;
const { itemName, category } = req.body;
@@ -258,7 +251,7 @@ router.post('/watched-items', async (req, res, next) => {
/**
* DELETE /api/users/watched-items/:masterItemId - Remove an item from the watchlist.
*/
router.delete('/watched-items/:masterItemId', async (req, res, next) => {
router.delete('/watched-items/:masterItemId', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
const user = req.user as UserProfile;
const masterItemId = parseInt(req.params.masterItemId, 10);
@@ -274,7 +267,7 @@ router.delete('/watched-items/:masterItemId', async (req, res, next) => {
/**
* GET /api/users/shopping-lists - Get all shopping lists for the user.
*/
router.get('/shopping-lists', async (req, res, next) => {
router.get('/shopping-lists', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile;
try {
@@ -289,7 +282,7 @@ router.get('/shopping-lists', async (req, res, next) => {
/**
* POST /api/users/shopping-lists - Create a new shopping list.
*/
router.post('/shopping-lists', async (req, res, next) => {
router.post('/shopping-lists', validateBody(['name']), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile;
const { name } = req.body;
@@ -305,7 +298,7 @@ router.post('/shopping-lists', async (req, res, next) => {
/**
* DELETE /api/users/shopping-lists/:listId - Delete a shopping list.
*/
router.delete('/shopping-lists/:listId', async (req, res, next) => {
router.delete('/shopping-lists/:listId', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
const user = req.user as UserProfile;
const listId = parseInt(req.params.listId, 10);
@@ -324,7 +317,7 @@ router.delete('/shopping-lists/:listId', async (req, res, next) => {
/**
* POST /api/users/shopping-lists/:listId/items - Add an item to a shopping list.
*/
router.post('/shopping-lists/:listId/items', async (req, res, next) => {
router.post('/shopping-lists/:listId/items', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
const listId = parseInt(req.params.listId, 10);
try {
@@ -339,7 +332,7 @@ router.post('/shopping-lists/:listId/items', async (req, res, next) => {
/**
* PUT /api/users/shopping-lists/items/:itemId - Update a shopping list item.
*/
router.put('/shopping-lists/items/:itemId', async (req, res, next) => {
router.put('/shopping-lists/items/:itemId', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
const itemId = parseInt(req.params.itemId, 10);
try {
@@ -354,7 +347,7 @@ router.put('/shopping-lists/items/:itemId', async (req, res, next) => {
/**
* DELETE /api/users/shopping-lists/items/:itemId - Remove an item from a shopping list.
*/
router.delete('/shopping-lists/items/:itemId', async (req, res, next) => {
router.delete('/shopping-lists/items/:itemId', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
const itemId = parseInt(req.params.itemId, 10);
if (isNaN(itemId)) {
@@ -372,7 +365,7 @@ router.delete('/shopping-lists/items/:itemId', async (req, res, next) => {
/**
* PUT /api/users/profile/preferences - Update user preferences.
*/
router.put('/profile/preferences', async (req, res, next) => {
router.put('/profile/preferences', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
const user = req.user as UserProfile;
if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) {
@@ -387,7 +380,7 @@ router.put('/profile/preferences', async (req, res, next) => {
}
});
router.get('/me/dietary-restrictions', async (req, res, next) => {
router.get('/me/dietary-restrictions', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile;
try {
@@ -399,7 +392,7 @@ router.get('/me/dietary-restrictions', async (req, res, next) => {
}
});
router.put('/me/dietary-restrictions', async (req, res, next) => {
router.put('/me/dietary-restrictions', validateBody(['restrictionIds']), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile;
const { restrictionIds } = req.body;
@@ -412,7 +405,7 @@ router.put('/me/dietary-restrictions', async (req, res, next) => {
}
});
router.get('/me/appliances', async (req, res, next) => {
router.get('/me/appliances', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile;
try {
@@ -424,7 +417,7 @@ router.get('/me/appliances', async (req, res, next) => {
}
});
router.put('/me/appliances', async (req, res, next) => {
router.put('/me/appliances', validateBody(['applianceIds']), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile;
const { applianceIds } = req.body;
@@ -441,7 +434,7 @@ router.put('/me/appliances', async (req, res, next) => {
* GET /api/users/addresses/:addressId - Get a specific address by its ID.
* This is protected to ensure a user can only fetch their own address details.
*/
router.get('/addresses/:addressId', async (req, res, next) => {
router.get('/addresses/:addressId', async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
const addressId = parseInt(req.params.addressId, 10);
@@ -464,7 +457,7 @@ router.get('/addresses/:addressId', async (req, res, next) => {
/**
* PUT /api/users/profile/address - Create or update the user's primary address.
*/
router.put('/profile/address', async (req, res, next) => {
router.put('/profile/address', async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
const addressData = req.body as Partial<Address>;

View File

@@ -0,0 +1,907 @@
// src/services/apiClient.test.ts
import { describe, it, expect, vi, beforeAll, afterAll, afterEach, beforeEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Unmock the module under test to ensure we are testing the real implementation.
vi.unmock('./apiClient');
import * as apiClient from './apiClient';
// Mock the logger to keep test output clean and verifiable.
vi.mock('./logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
// Mock localStorage for token storage, as it's used by apiFetch.
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Setup MSW mock server.
const server = setupServer();
describe('API Client', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
localStorageMock.clear();
vi.clearAllMocks();
});
afterAll(() => server.close());
describe('apiFetch', () => {
it('should add Authorization header if token exists in localStorage', async () => {
localStorage.setItem('authToken', 'test-token-123');
// Set up a handler to capture the request headers.
let capturedHeaders: Headers | null = null;
server.use(
http.get('http://localhost/api/users/profile', ({ request }) => {
capturedHeaders = request.headers;
return HttpResponse.json({ success: true });
})
);
await apiClient.apiFetch('/users/profile');
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token-123');
});
it('should not add Authorization header if no token exists', async () => {
let capturedHeaders: Headers | null = null;
server.use(
http.get('http://localhost/api/public-data', ({ request }) => {
capturedHeaders = request.headers;
return HttpResponse.json({ success: true });
})
);
await apiClient.apiFetch('/public-data');
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!.has('Authorization')).toBe(false);
});
it('should handle token refresh on 401 response', async () => {
localStorage.setItem('authToken', 'expired-token');
// 1. First request with expired token should return 401.
server.use(
http.get('http://localhost/api/users/profile', ({ request }) => {
if (request.headers.get('Authorization') === 'Bearer expired-token') {
return new HttpResponse(null, { status: 401 });
}
// 3. Second (retried) request with new token should succeed.
if (request.headers.get('Authorization') === 'Bearer new-refreshed-token') {
return HttpResponse.json({ user_id: 'user-123' });
}
return new HttpResponse('Unexpected request', { status: 500 });
})
);
// 2. The refresh endpoint should be called and return a new token.
server.use(
http.post('http://localhost/api/auth/refresh-token', () => {
return HttpResponse.json({ token: 'new-refreshed-token' });
})
);
const response = await apiClient.apiFetch('/users/profile');
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ user_id: 'user-123' });
// Verify the new token was stored in localStorage.
expect(localStorage.getItem('authToken')).toBe('new-refreshed-token');
});
it('should reject if token refresh fails', async () => {
localStorage.setItem('authToken', 'expired-token');
// Mock the initial 401 response.
server.use(http.get('http://localhost/api/users/profile', () => new HttpResponse(null, { status: 401 })));
// Mock the refresh endpoint to also fail.
server.use(http.post('http://localhost/api/auth/refresh-token', () => new HttpResponse(null, { status: 403 })));
// The apiFetch call should ultimately reject.
await expect(apiClient.apiFetch('/users/profile')).rejects.toThrow();
});
});
describe('apiFetch (with FormData)', () => {
it('should handle FormData correctly by not setting Content-Type', async () => {
localStorage.setItem('authToken', 'form-data-token');
const formData = new FormData();
formData.append('file', new File(['content'], 'test.jpg'));
let capturedHeaders: Headers | null = null;
server.use(
http.post('http://localhost/api/ai/upload-and-process', ({ request }) => {
capturedHeaders = request.headers;
return HttpResponse.json({ success: true });
})
);
await apiClient.apiFetch('/ai/upload-and-process', {
method: 'POST',
body: formData,
});
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!.get('Authorization')).toBe('Bearer form-data-token');
// Crucially, Content-Type should NOT be set by our code, but by the browser.
// It will contain a boundary, so we check that it's not 'application/json'.
expect(capturedHeaders!.get('Content-Type')).toContain('multipart/form-data'); // This assertion is correct.
});
});
describe('Specific API Functions', () => {
beforeEach(() => {
localStorage.setItem('authToken', 'specific-api-token');
});
it('getAuthenticatedUserProfile should call the correct endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/users/profile', () => {
wasCalled = true;
return HttpResponse.json({ user_id: 'user-123' });
})
);
await apiClient.getAuthenticatedUserProfile();
expect(wasCalled).toBe(true);
});
it('addWatchedItem should send a POST request with the correct body', async () => {
let capturedBody: any = null;
server.use(
http.post('http://localhost/api/users/watched-items', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true }, { status: 201 });
})
);
await apiClient.addWatchedItem('Apples', 'Produce');
expect(capturedBody).toEqual({ itemName: 'Apples', category: 'Produce' });
});
it('removeWatchedItem should send a DELETE request to the correct URL', async () => {
let wasCalled = false;
server.use(
http.delete('http://localhost/api/users/watched-items/99', () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.removeWatchedItem(99);
expect(wasCalled).toBe(true);
});
});
describe('Budget API Functions', () => {
it('getBudgets should call the correct endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/budgets', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.getBudgets();
expect(wasCalled).toBe(true);
});
it('createBudget should send a POST request with budget data', async () => {
let capturedBody: any = null;
server.use(
http.post('http://localhost/api/budgets', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true }, { status: 201 });
})
);
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
await apiClient.createBudget(budgetData);
expect(capturedBody).toEqual(budgetData);
});
it('updateBudget should send a PUT request with the correct data and ID', async () => {
let capturedBody: any = null;
server.use(
http.put('http://localhost/api/budgets/123', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const budgetUpdates = { amount_cents: 60000 };
await apiClient.updateBudget(123, budgetUpdates);
expect(capturedBody).toEqual(budgetUpdates);
});
it('deleteBudget should send a DELETE request to the correct URL', async () => {
let wasCalled = false;
server.use(
http.delete('http://localhost/api/budgets/456', () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.deleteBudget(456);
expect(wasCalled).toBe(true);
});
it('getSpendingAnalysis should send a GET request with correct query params', async () => {
let capturedUrl: URL | null = null;
server.use(
http.get('http://localhost/api/budgets/spending-analysis', ({ request }) => {
capturedUrl = new URL(request.url);
return HttpResponse.json([]);
})
);
await apiClient.getSpendingAnalysis('2024-01-01', '2024-01-31');
expect(capturedUrl!.searchParams.get('startDate')).toBe('2024-01-01');
expect(capturedUrl!.searchParams.get('endDate')).toBe('2024-01-31');
});
});
describe('Gamification API Functions', () => {
it('getUserAchievements should call the authenticated endpoint', async () => {
localStorage.setItem('authToken', 'gamify-token');
let capturedHeaders: Headers | null = null;
server.use(
http.get('http://localhost/api/achievements/me', ({ request }) => {
capturedHeaders = request.headers;
return HttpResponse.json([]);
})
);
await apiClient.getUserAchievements();
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!.get('Authorization')).toBe('Bearer gamify-token');
});
it('fetchLeaderboard should send a GET request with a limit query param', async () => {
let capturedUrl: URL | null = null;
server.use(
http.get('http://localhost/api/achievements/leaderboard', ({ request }) => {
capturedUrl = new URL(request.url);
return HttpResponse.json([]);
})
);
await apiClient.fetchLeaderboard(5);
expect(capturedUrl).not.toBeNull(); // This assertion ensures capturedUrl is not null for the next line
expect(capturedUrl!.searchParams.get('limit')).toBe('5');
});
it('uploadAvatar should send FormData with the avatar file', async () => {
localStorage.setItem('authToken', 'avatar-token');
const mockFile = new File(['avatar-content'], 'my-avatar.png', { type: 'image/png' });
let capturedBody: FormData | null = null;
let capturedHeaders: Headers | null = null;
server.use(
http.post('http://localhost/api/users/profile/avatar', async ({ request }) => {
capturedHeaders = request.headers;
capturedBody = await request.formData();
return HttpResponse.json({ success: true });
})
);
await apiClient.uploadAvatar(mockFile);
expect(capturedHeaders!.get('Authorization')).toBe('Bearer avatar-token');
expect(capturedBody).not.toBeNull();
// Using non-null assertion (!) because we asserted not.toBeNull() above.
const uploadedFile = capturedBody!.get('avatar') as File;
expect(uploadedFile.name).toBe('my-avatar.png');
});
});
describe('Notification API Functions', () => {
it('getNotifications should call the correct endpoint with query params', async () => {
let capturedUrl: URL | null = null;
server.use(
http.get('http://localhost/api/users/notifications', ({ request }) => {
capturedUrl = new URL(request.url);
return HttpResponse.json([]);
})
);
await apiClient.getNotifications(10, 20);
expect(capturedUrl).not.toBeNull();
expect(capturedUrl!.pathname).toBe('/api/users/notifications');
expect(capturedUrl!.searchParams.get('limit')).toBe('10');
expect(capturedUrl!.searchParams.get('offset')).toBe('20');
});
it('markAllNotificationsAsRead should send a POST request', async () => {
let wasCalled = false;
server.use(
http.post('http://localhost/api/users/notifications/mark-all-read', () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.markAllNotificationsAsRead();
expect(wasCalled).toBe(true);
});
it('markNotificationAsRead should send a POST request to the correct URL', async () => {
const notificationId = 123;
let wasCalled = false;
server.use(
http.post(`http://localhost/api/users/notifications/${notificationId}/mark-read`, () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.markNotificationAsRead(notificationId);
expect(wasCalled).toBe(true);
});
});
describe('Shopping List API Functions', () => {
beforeEach(async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/users/shopping-lists', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.fetchShoppingLists();
expect(wasCalled).toBe(true);
});
it('createShoppingList should send a POST request with the list name', async () => {
let capturedBody: { name: string } | null = null;
server.use(
http.post('http://localhost/api/users/shopping-lists', async ({ request }) => {
capturedBody = await request.json() as { name: string };
return HttpResponse.json({ success: true }, { status: 201 });
})
);
await apiClient.createShoppingList('Weekly Groceries');
expect(capturedBody).toEqual({ name: 'Weekly Groceries' });
});
it('deleteShoppingList should send a DELETE request to the correct URL', async () => {
const listId = 42;
let wasCalled = false;
server.use(
http.delete(`http://localhost/api/users/shopping-lists/${listId}`, () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.deleteShoppingList(listId);
expect(wasCalled).toBe(true);
});
it('addShoppingListItem should send a POST request with item data', async () => {
const listId = 42;
const itemData = { customItemName: 'Paper Towels' };
let capturedBody: { customItemName: string } | null = null;
server.use(
http.post(`http://localhost/api/users/shopping-lists/${listId}/items`, async ({ request }) => {
capturedBody = await request.json() as { customItemName: string };
return HttpResponse.json({ success: true }, { status: 201 });
})
);
await apiClient.addShoppingListItem(listId, itemData);
expect(capturedBody).toEqual(itemData);
});
it('updateShoppingListItem should send a PUT request with update data', async () => {
const itemId = 101;
const updates = { is_purchased: true };
let capturedBody: { is_purchased: boolean } | null = null;
server.use(
http.put(`http://localhost/api/users/shopping-lists/items/${itemId}`, async ({ request }) => {
capturedBody = await request.json() as { is_purchased: boolean };
return HttpResponse.json({ success: true });
})
);
await apiClient.updateShoppingListItem(itemId, updates);
expect(capturedBody).toEqual(updates);
});
it('removeShoppingListItem should send a DELETE request to the correct URL', async () => {
const itemId = 101;
let wasCalled = false;
server.use(
http.delete(`http://localhost/api/users/shopping-lists/items/${itemId}`, () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.removeShoppingListItem(itemId);
expect(wasCalled).toBe(true);
});
it('completeShoppingList should send a POST request with total spent', async () => {
const listId = 77;
const totalSpentCents = 12345;
let capturedBody: { totalSpentCents: number } | null = null;
server.use(
http.post(`http://localhost/api/users/shopping-lists/${listId}/complete`, async ({ request }) => {
capturedBody = await request.json() as { totalSpentCents: number };
return HttpResponse.json({ success: true });
})
);
await apiClient.completeShoppingList(listId, totalSpentCents);
expect(capturedBody).toEqual({ totalSpentCents });
});
});
describe('Recipe API Functions', () => {
beforeEach(() => {
// Most recipe endpoints require authentication.
localStorage.setItem('authToken', 'recipe-token');
});
it('getCompatibleRecipes should call the correct endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/users/me/compatible-recipes', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.getCompatibleRecipes();
expect(wasCalled).toBe(true);
});
it('forkRecipe should send a POST request to the correct URL', async () => {
const recipeId = 99;
let wasCalled = false;
server.use(
http.post(`http://localhost/api/recipes/${recipeId}/fork`, () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.forkRecipe(recipeId);
expect(wasCalled).toBe(true);
});
it('getUserFavoriteRecipes should call the correct endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/users/me/favorite-recipes', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.getUserFavoriteRecipes();
expect(wasCalled).toBe(true);
});
it('addFavoriteRecipe should send a POST request with the recipeId', async () => {
const recipeId = 123;
let capturedBody: { recipeId: number } | null = null;
server.use(
http.post('http://localhost/api/users/me/favorite-recipes', async ({ request }) => {
capturedBody = await request.json() as { recipeId: number };
return HttpResponse.json({ success: true });
})
);
await apiClient.addFavoriteRecipe(recipeId);
expect(capturedBody).toEqual({ recipeId });
});
it('removeFavoriteRecipe should send a DELETE request to the correct URL', async () => {
const recipeId = 123;
let wasCalled = false;
server.use(
http.delete(`http://localhost/api/users/me/favorite-recipes/${recipeId}`, () => {
wasCalled = true;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.removeFavoriteRecipe(recipeId);
expect(wasCalled).toBe(true);
});
it('getRecipeComments should call the public endpoint', async () => {
const recipeId = 456;
let wasCalled = false;
server.use(
http.get(`http://localhost/api/recipes/${recipeId}/comments`, () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.getRecipeComments(recipeId);
expect(wasCalled).toBe(true);
});
it('addRecipeComment should send a POST request with content and optional parentId', async () => {
const recipeId = 456;
const commentData = { content: 'This is a reply', parentCommentId: 789 };
let capturedBody: typeof commentData | null = null;
server.use(
http.post(`http://localhost/api/recipes/${recipeId}/comments`, async ({ request }) => {
capturedBody = await request.json() as typeof commentData;
return HttpResponse.json({ success: true }, { status: 201 });
})
);
await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId);
expect(capturedBody).toEqual(commentData);
});
});
describe('User Profile and Settings API Functions', () => {
it('updateUserProfile should send a PUT request with profile data', async () => {
const profileData = { full_name: 'John Doe' };
let capturedBody: typeof profileData | null = null;
server.use(
http.put('http://localhost/api/users/profile', async ({ request }) => {
capturedBody = await request.json() as typeof profileData;
return HttpResponse.json({ success: true });
})
);
await apiClient.updateUserProfile(profileData);
expect(capturedBody).toEqual(profileData);
});
it('updateUserPreferences should send a PUT request with preferences data', async () => {
const preferences = { darkMode: true };
let capturedBody: typeof preferences | null = null;
server.use(
http.put('http://localhost/api/users/profile/preferences', async ({ request }) => {
capturedBody = await request.json() as typeof preferences;
return HttpResponse.json({ success: true });
})
);
await apiClient.updateUserPreferences(preferences);
expect(capturedBody).toEqual(preferences);
});
it('updateUserPassword should send a PUT request with the new password', async () => {
const passwordData = { newPassword: 'new-secure-password' };
let capturedBody: typeof passwordData | null = null;
server.use(
http.put('http://localhost/api/users/profile/password', async ({ request }) => {
capturedBody = await request.json() as typeof passwordData;
return HttpResponse.json({ success: true });
})
);
await apiClient.updateUserPassword(passwordData.newPassword);
expect(capturedBody).toEqual(passwordData);
});
it('deleteUserAccount should send a DELETE request with the confirmation password', async () => {
const passwordData = { password: 'current-password-for-confirmation' };
let capturedBody: typeof passwordData | null = null;
server.use(
http.delete('http://localhost/api/users/account', async ({ request }) => {
capturedBody = await request.json() as typeof passwordData;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.deleteUserAccount(passwordData.password);
expect(capturedBody).toEqual(passwordData);
});
it('setUserDietaryRestrictions should send a PUT request with restriction IDs', async () => {
const restrictionData = { restrictionIds: [1, 5] };
let capturedBody: typeof restrictionData | null = null;
server.use(
http.put('http://localhost/api/users/me/dietary-restrictions', async ({ request }) => {
capturedBody = await request.json() as typeof restrictionData;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.setUserDietaryRestrictions(restrictionData.restrictionIds);
expect(capturedBody).toEqual(restrictionData);
});
it('setUserAppliances should send a PUT request with appliance IDs', async () => {
const applianceData = { applianceIds: [2, 8] };
let capturedBody: typeof applianceData | null = null;
server.use(
http.put('http://localhost/api/users/appliances', async ({ request }) => {
capturedBody = await request.json() as typeof applianceData;
return new HttpResponse(null, { status: 204 });
})
);
await apiClient.setUserAppliances(applianceData.applianceIds);
expect(capturedBody).toEqual(applianceData);
});
it('updateUserAddress should send a PUT request with address data', async () => {
const addressData = { address_line_1: '123 Main St', city: 'Anytown' };
let capturedBody: typeof addressData | null = null;
server.use(
http.put('http://localhost/api/users/profile/address', async ({ request }) => {
capturedBody = await request.json() as typeof addressData;
return HttpResponse.json({ success: true });
})
);
await apiClient.updateUserAddress(addressData);
expect(capturedBody).toEqual(addressData);
});
});
describe('Public API Functions', () => {
it('pingBackend should call the correct health check endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/health/ping', () => {
wasCalled = true;
return HttpResponse.text('pong');
})
);
await apiClient.pingBackend();
expect(wasCalled).toBe(true);
});
it('checkDbSchema should call the correct health check endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/health/db-schema', () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.checkDbSchema();
expect(wasCalled).toBe(true);
});
it('checkStorage should call the correct health check endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/health/storage', () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.checkStorage();
expect(wasCalled).toBe(true);
});
it('checkDbPoolHealth should call the correct health check endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/health/db-pool', () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.checkDbPoolHealth();
expect(wasCalled).toBe(true);
});
it('checkRedisHealth should call the correct health check endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/health/redis', () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.checkRedisHealth();
expect(wasCalled).toBe(true);
});
it('checkPm2Status should call the correct system endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/system/pm2-status', () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.checkPm2Status();
expect(wasCalled).toBe(true);
});
it('fetchFlyers should call the correct public endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/flyers', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.fetchFlyers();
expect(wasCalled).toBe(true);
});
it('fetchMasterItems should call the correct public endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/master-items', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.fetchMasterItems();
expect(wasCalled).toBe(true);
});
it('fetchCategories should call the correct public endpoint', async () => {
let wasCalled = false;
server.use(
http.get('http://localhost/api/categories', () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.fetchCategories();
expect(wasCalled).toBe(true);
});
it('fetchFlyerItems should call the correct public endpoint for a specific flyer', async () => {
const flyerId = 123;
let wasCalled = false;
server.use(
http.get(`http://localhost/api/flyers/${flyerId}/items`, () => {
wasCalled = true;
return HttpResponse.json([]);
})
);
await apiClient.fetchFlyerItems(flyerId);
expect(wasCalled).toBe(true);
});
it('fetchFlyerItemsForFlyers should send a POST request with flyer IDs', async () => {
const flyerIds = [1, 2, 3];
let capturedBody: { flyerIds: number[] } | null = null;
server.use(
http.post('http://localhost/api/flyer-items/batch-fetch', async ({ request }) => {
capturedBody = await request.json() as { flyerIds: number[] };
return HttpResponse.json([]);
})
);
await apiClient.fetchFlyerItemsForFlyers(flyerIds);
expect(capturedBody).toEqual({ flyerIds });
});
it('countFlyerItemsForFlyers should send a POST request with flyer IDs', async () => {
const flyerIds = [1, 2, 3];
let capturedBody: { flyerIds: number[] } | null = null;
server.use(
http.post('http://localhost/api/flyer-items/batch-count', async ({ request }) => {
capturedBody = await request.json() as { flyerIds: number[] };
return HttpResponse.json({ count: 0 });
})
);
await apiClient.countFlyerItemsForFlyers(flyerIds);
expect(capturedBody).toEqual({ flyerIds });
});
it('fetchHistoricalPriceData should send a POST request with master item IDs', async () => {
const masterItemIds = [10, 20];
let capturedBody: { masterItemIds: number[] } | null = null;
server.use(
http.post('http://localhost/api/price-history', async ({ request }) => {
capturedBody = await request.json() as { masterItemIds: number[] };
return HttpResponse.json([]);
})
);
await apiClient.fetchHistoricalPriceData(masterItemIds);
expect(capturedBody).toEqual({ masterItemIds });
});
});
describe('Admin API Functions', () => {
it('approveCorrection should send a POST request to the correct URL', async () => {
const correctionId = 45;
let wasCalled = false;
server.use(
http.post(`http://localhost/api/admin/corrections/${correctionId}/approve`, () => {
wasCalled = true;
return HttpResponse.json({ success: true });
})
);
await apiClient.approveCorrection(correctionId);
expect(wasCalled).toBe(true);
});
it('updateRecipeStatus should send a PUT request with the correct body', async () => {
const recipeId = 78;
const statusUpdate = { status: 'public' as const };
let capturedBody: typeof statusUpdate | null = null;
server.use(
http.put(`http://localhost/api/admin/recipes/${recipeId}/status`, async ({ request }) => {
capturedBody = await request.json() as typeof statusUpdate;
return HttpResponse.json({ success: true });
})
);
await apiClient.updateRecipeStatus(recipeId, 'public');
expect(capturedBody).toEqual(statusUpdate);
});
it('cleanupFlyerFiles should send a POST request to the correct URL', async () => {
const flyerId = 99;
let wasCalled = false;
server.use(
http.post(`http://localhost/api/admin/flyers/${flyerId}/cleanup`, () => {
wasCalled = true;
return HttpResponse.json({ success: true }, { status: 202 });
})
);
await apiClient.cleanupFlyerFiles(flyerId);
expect(wasCalled).toBe(true);
});
});
});

View File

@@ -61,7 +61,7 @@ const refreshToken = async (): Promise<string> => {
if (typeof window !== 'undefined') {
localStorage.removeItem('authToken');
// A hard redirect is a simple way to reset the app state to logged-out.
window.location.href = '/';
// window.location.href = '/'; // Removed to allow the caller to handle session expiry.
}
throw error;
}
@@ -89,6 +89,12 @@ export const apiFetch = async (url: string, options: RequestInit = {}, tokenOver
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
// Do not set Content-Type for FormData. The browser must set it with the
// correct multipart boundary. For all other requests, default to application/json.
if (!(options.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const newOptions = { ...options, headers };
@@ -134,36 +140,6 @@ export const apiFetch = async (url: string, options: RequestInit = {}, tokenOver
return response;
};
/**
* A specialized fetch wrapper for FormData uploads that require authentication.
* It correctly adds the Authorization header but crucially AVOIDS setting a
* 'Content-Type' header, allowing the browser to set it automatically with the
* correct multipart boundary. Using the main `apiFetch` for FormData will fail
* because it defaults to 'application/json'.
* @param url The URL to fetch.
* @param options The fetch options, which must include a FormData body.
* @returns A promise that resolves to the fetch Response.
*/
export const apiFetchWithAuth = async (url: string, options: RequestInit, tokenOverride?: string): Promise<Response> => {
// Always construct the full URL from the base and the provided path.
const fullUrl = url.startsWith('http') ? url : joinUrl(API_BASE_URL, url);
logger.debug(`apiFetchWithAuth: ${options.method || 'GET'} ${fullUrl}`);
const headers = new Headers(options.headers || {});
const token = tokenOverride ?? (typeof window !== 'undefined' ? localStorage.getItem('authToken') : null);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
// IMPORTANT: Do NOT set Content-Type. The browser handles it for FormData.
const newOptions = { ...options, headers };
// This does not need the full token refresh logic of apiFetch, because if the token
// is expired, the user will be logged out on the next page navigation anyway.
return fetch(fullUrl, newOptions);
};
/**
* Pings the backend server to check if it's running and reachable.
* @returns A promise that resolves to true if the server responds with 'pong'.
@@ -423,15 +399,14 @@ export async function loginUser(email: string, password: string, rememberMe: boo
* @returns A promise that resolves with the backend's response, including the newly created receipt record.
*/
export const uploadReceipt = async (receiptImage: File, tokenOverride?: string): Promise<Response> => {
const formData = new FormData();
formData.append('receiptImage', receiptImage);
const formData = new FormData();
formData.append('receiptImage', receiptImage);
// Use apiFetch to ensure the user is authenticated for this action.
// The browser will automatically set the correct 'Content-Type' for FormData.
return apiFetch(`/receipts/upload`, {
method: 'POST',
body: formData,
}, tokenOverride);
// Use apiFetch, which now correctly handles FormData.
return apiFetch(`/receipts/upload`, {
method: 'POST',
body: formData,
}, tokenOverride);
};
/**
@@ -446,29 +421,21 @@ export const getDealsForReceipt = async (receiptId: number, tokenOverride?: stri
// --- Analytics & Shopping Enhancement API Functions ---
export const trackFlyerItemInteraction = async (itemId: number, type: 'view' | 'click'): Promise<void> => {
try {
fetch(`${API_BASE_URL}/flyer-items/${itemId}/track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
keepalive: true // Helps ensure the request is sent even if the page is closing
});
} catch (error) {
logger.warn('Failed to track flyer item interaction', { error });
}
// Return the promise to allow the caller to handle potential errors.
fetch(`${API_BASE_URL}/flyer-items/${itemId}/track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
keepalive: true // Helps ensure the request is sent even if the page is closing
}).catch(error => logger.warn('Failed to track flyer item interaction', { error }));
};
export const logSearchQuery = async (query: Omit<SearchQuery, 'id' | 'created_at' | 'user_id'>, tokenOverride?: string): Promise<void> => {
try {
apiFetch(`/search/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query),
keepalive: true
}, tokenOverride);
} catch (error) {
logger.warn('Failed to log search query', { error });
}
apiFetch(`/search/log`, {
method: 'POST',
body: JSON.stringify(query),
keepalive: true
}, tokenOverride).catch(error => logger.warn('Failed to log search query', { error }));
};
export const getPantryLocations = async (tokenOverride?: string): Promise<Response> => {
@@ -986,7 +953,6 @@ export const uploadAvatar = async (avatarFile: File, tokenOverride?: string): Pr
const formData = new FormData();
formData.append('avatar', avatarFile);
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
// The URL must be relative, as the helper constructs the full path.
return apiFetchWithAuth('/users/profile/avatar', { method: 'POST', body: formData }, tokenOverride);
// Use apiFetch, which now correctly handles FormData.
return apiFetch('/users/profile/avatar', { method: 'POST', body: formData }, tokenOverride);
};

View File

@@ -1,13 +1,24 @@
// src/services/db/flyer.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { createMockFlyer, createMockFlyerItem } from '../../tests/utils/mockFactories';
import { createMockFlyer, createMockFlyerItem, createMockBrand } from '../../tests/utils/mockFactories';
// Un-mock the module we are testing to ensure we use the real implementation
vi.unmock('./flyer.db');
import { insertFlyer, insertFlyerItems, createFlyerAndItems } from './flyer.db';
import type { FlyerInsert, FlyerItemInsert } from '../../types';
import {
insertFlyer,
insertFlyerItems,
createFlyerAndItems,
getAllBrands,
getFlyerById,
getFlyers,
getFlyerItems,
getFlyerItemsForFlyers,
countFlyerItemsForFlyers,
findFlyerByChecksum,
} from './flyer.db';
import type { FlyerInsert, FlyerItemInsert, Brand, Flyer, FlyerItem } from '../../types';
// Mock dependencies
vi.mock('../logger.server', () => ({
@@ -16,11 +27,12 @@ vi.mock('../logger.server', () => ({
describe('Flyer DB Service', () => {
beforeEach(() => {
// To correctly mock a transaction, the `connect` method should return an object
// that has `query` and `release` methods. Here, we make `connect` return the
// pool instance itself, and ensure the `release` method is present on it.
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockPoolInstance as any);
(mockPoolInstance as any).release = vi.fn(); // Add the missing release method
// In a transaction, `pool.connect()` returns a client. That client has a `release` method.
// For these tests, we simulate this by having `connect` resolve to the pool instance itself,
// and we ensure the `release` method is mocked on that instance.
const mockClient = { ...mockPoolInstance, release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
vi.clearAllMocks();
});
@@ -113,7 +125,7 @@ describe('Flyer DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith('COMMIT');
expect(mockPoolInstance.query).not.toHaveBeenCalledWith('ROLLBACK');
expect(mockPoolInstance.release).toHaveBeenCalled();
expect(vi.mocked(mockPoolInstance.connect).mock.results[0].value.release).toHaveBeenCalled();
// Verify the individual functions were called with the client
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyers'), expect.any(Array));
@@ -137,7 +149,96 @@ describe('Flyer DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockPoolInstance.query).not.toHaveBeenCalledWith('COMMIT');
expect(mockPoolInstance.release).toHaveBeenCalled();
expect(vi.mocked(mockPoolInstance.connect).mock.results[0].value.release).toHaveBeenCalled();
});
});
describe('getAllBrands', () => {
it('should execute the correct SELECT query and return brands', async () => {
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Test Brand' })];
mockPoolInstance.query.mockResolvedValue({ rows: mockBrands });
const result = await getAllBrands();
expect(result).toEqual(mockBrands);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.stores s'));
});
});
describe('getFlyerById', () => {
it('should return a flyer if found', async () => {
const mockFlyer = createMockFlyer({ flyer_id: 123 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await getFlyerById(123);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE flyer_id = $1', [123]);
});
});
describe('getFlyers', () => {
it('should use default limit and offset when none are provided', async () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
await getFlyers();
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[20, 0] // Default values
);
});
it('should use provided limit and offset values', async () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
await getFlyers(10, 5);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[10, 5] // Provided values
);
});
});
describe('getFlyerItems', () => {
it('should return items for a specific flyer', async () => {
const mockItems: FlyerItem[] = [createMockFlyerItem({ flyer_id: 456 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await getFlyerItems(456);
expect(result).toEqual(mockItems);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE flyer_id = $1'), [456]);
});
});
describe('getFlyerItemsForFlyers', () => {
it('should return items for multiple flyers using ANY', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getFlyerItemsForFlyers([1, 2, 3]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('flyer_id = ANY($1::int[])'), [[1, 2, 3]]);
});
});
describe('countFlyerItemsForFlyers', () => {
it('should return the total count of items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ count: '42' }] });
const result = await countFlyerItemsForFlyers([1, 2]);
expect(result).toBe(42);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT COUNT(*)'), [[1, 2]]);
});
});
describe('findFlyerByChecksum', () => {
it('should return a flyer for a given checksum', async () => {
const mockFlyer = createMockFlyer({ checksum: 'abc' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await findFlyerByChecksum('abc');
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE checksum = $1', ['abc']);
});
});
});

View File

@@ -109,6 +109,8 @@ export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: Fl
/**
* Retrieves all distinct brands from the stores table.
* In this application's context, a "store" (e.g., Walmart, Sobeys) is synonymous
* with a "brand". This function fetches those entities.
* @returns A promise that resolves to an array of Brand objects.
*/
export async function getAllBrands(): Promise<Brand[]> {
@@ -135,4 +137,75 @@ export async function getFlyerById(flyerId: number): Promise<Flyer | undefined>
[flyerId]
);
return res.rows[0];
}
/**
* Retrieves all flyers from the database, ordered by creation date.
* Supports pagination.
* @param limit The maximum number of flyers to return.
* @param offset The number of flyers to skip.
* @returns A promise that resolves to an array of Flyer objects.
*/
export async function getFlyers(limit: number = 20, offset: number = 0): Promise<Flyer[]> {
const res = await getPool().query<Flyer>(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
);
return res.rows;
}
/**
* Retrieves all items for a specific flyer.
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
export async function getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
const res = await getPool().query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
[flyerId]
);
return res.rows;
}
/**
* Retrieves all flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to an array of all matching FlyerItem objects.
*/
export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
const res = await getPool().query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC',
[flyerIds]
);
return res.rows;
}
/**
* Counts the total number of flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to the total count of items.
*/
export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
if (flyerIds.length === 0) {
return 0;
}
const res = await getPool().query<{ count: string }>(
'SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])',
[flyerIds]
);
// The COUNT(*) result from pg is a string, so it needs to be parsed.
return parseInt(res.rows[0].count, 10);
}
/**
* Finds a single flyer by its SHA-256 checksum.
* @param checksum The checksum of the flyer file to find.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
export async function findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
const res = await getPool().query<Flyer>(
'SELECT * FROM public.flyers WHERE checksum = $1',
[checksum]
);
return res.rows[0];
}

View File

@@ -1,12 +1,14 @@
// src/services/db/personalization.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getAllMasterItems,
getWatchedItems,
addWatchedItem,
removeWatchedItem,
findRecipesFromPantry,
recommendRecipesForUser,
getBestSalePricesForUser,
getBestSalePricesForAllUsers,
suggestPantryItemConversions,
findPantryItemOwner,
getDietaryRestrictions,
@@ -54,6 +56,18 @@ describe('Personalization DB Service', () => {
vi.clearAllMocks();
});
describe('getAllMasterItems', () => {
it('should execute the correct query and return master items', async () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
mockQuery.mockResolvedValue({ rows: mockItems });
const result = await getAllMasterItems();
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
expect(result).toEqual(mockItems);
});
});
describe('getWatchedItems', () => {
it('should execute the correct query and return watched items', async () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
@@ -128,6 +142,14 @@ describe('Personalization DB Service', () => {
});
});
describe('getBestSalePricesForAllUsers', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getBestSalePricesForAllUsers();
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()');
});
});
describe('suggestPantryItemConversions', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });

View File

@@ -1,5 +1,5 @@
// src/tests/utils/mockFactories.ts
import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem } from '../../types';
import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem, DietaryRestriction, Appliance } from '../../types';
/**
* Creates a mock UserProfile object for use in tests, ensuring type safety.
@@ -358,4 +358,26 @@ export const createMockShoppingListItem = (overrides: Partial<ShoppingListItem>
};
return { ...defaultItem, ...overrides };
};
/**
* Creates a mock DietaryRestriction object for testing.
* @param overrides - Optional properties to override the defaults.
* @returns A mock DietaryRestriction object.
*/
export const createMockDietaryRestriction = (overrides: Partial<DietaryRestriction> = {}): DietaryRestriction => {
return {
dietary_restriction_id: 1,
name: 'Vegetarian',
type: 'diet',
...overrides,
};
};
export const createMockAppliance = (overrides: Partial<Appliance> = {}): Appliance => {
return {
appliance_id: 1,
name: 'Oven',
...overrides,
};
};