From ef074179786f06f0850b8a245720e07467314ca4 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Sun, 7 Dec 2025 14:29:37 -0800 Subject: [PATCH] moar unit test ! --- src/routes/admin.routes.ts | 2 +- src/routes/ai.routes.ts | 15 +- src/routes/auth.routes.ts | 34 +- src/routes/gamification.routes.ts | 17 +- src/routes/public.routes.test.ts | 58 +- src/routes/public.routes.ts | 105 ++- src/routes/user.routes.ts | 145 ++-- src/services/apiClient.test.ts | 907 +++++++++++++++++++++ src/services/apiClient.ts | 90 +- src/services/db/flyer.db.test.ts | 121 ++- src/services/db/flyer.db.ts | 73 ++ src/services/db/personalization.db.test.ts | 22 + src/tests/utils/mockFactories.ts | 24 +- 13 files changed, 1393 insertions(+), 220 deletions(-) create mode 100644 src/services/apiClient.test.ts diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 73aaa742..7e8c8b5e 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -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(); diff --git a/src/routes/ai.routes.ts b/src/routes/ai.routes.ts index 130210d8..1dc74038 100644 --- a/src/routes/ai.routes.ts +++ b/src/routes/ai.routes.ts @@ -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); } } ); diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index c5946b51..d1a9bfdc 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -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); }); diff --git a/src/routes/gamification.routes.ts b/src/routes/gamification.routes.ts index 94272551..f8d31270 100644 --- a/src/routes/gamification.routes.ts +++ b/src/routes/gamification.routes.ts @@ -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; \ No newline at end of file diff --git a/src/routes/public.routes.test.ts b/src/routes/public.routes.test.ts index 1d123726..0d2bfb39 100644 --- a/src/routes/public.routes.test.ts +++ b/src/routes/public.routes.test.ts @@ -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); + }); + }); }); diff --git a/src/routes/public.routes.ts b/src/routes/public.routes.ts index 7fd56f0b..0b72d028 100644 --- a/src/routes/public.routes.ts +++ b/src/routes/public.routes.ts @@ -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; \ No newline at end of file diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index c081443e..930cb846 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -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
; diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts new file mode 100644 index 00000000..a512ec04 --- /dev/null +++ b/src/services/apiClient.test.ts @@ -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 = {}; + 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); + }); + }); +}); \ No newline at end of file diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 61158a86..40c07678 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -61,7 +61,7 @@ const refreshToken = async (): Promise => { 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 => { - // 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 => { - 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 => { - 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, tokenOverride?: string): Promise => { - 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 => { @@ -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); }; \ No newline at end of file diff --git a/src/services/db/flyer.db.test.ts b/src/services/db/flyer.db.test.ts index e81fa33a..c8392f12 100644 --- a/src/services/db/flyer.db.test.ts +++ b/src/services/db/flyer.db.test.ts @@ -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']); }); }); }); \ No newline at end of file diff --git a/src/services/db/flyer.db.ts b/src/services/db/flyer.db.ts index 66ddae46..25624494 100644 --- a/src/services/db/flyer.db.ts +++ b/src/services/db/flyer.db.ts @@ -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 { @@ -135,4 +137,75 @@ export async function getFlyerById(flyerId: number): Promise [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 { + const res = await getPool().query( + '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 { + const res = await getPool().query( + '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 { + const res = await getPool().query( + '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 { + 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 { + const res = await getPool().query( + 'SELECT * FROM public.flyers WHERE checksum = $1', + [checksum] + ); + return res.rows[0]; } \ No newline at end of file diff --git a/src/services/db/personalization.db.test.ts b/src/services/db/personalization.db.test.ts index 62d7a746..ccf3aaf7 100644 --- a/src/services/db/personalization.db.test.ts +++ b/src/services/db/personalization.db.test.ts @@ -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: [] }); diff --git a/src/tests/utils/mockFactories.ts b/src/tests/utils/mockFactories.ts index 1977a943..591cb1e1 100644 --- a/src/tests/utils/mockFactories.ts +++ b/src/tests/utils/mockFactories.ts @@ -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 }; 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 => { + return { + dietary_restriction_id: 1, + name: 'Vegetarian', + type: 'diet', + ...overrides, + }; +}; + +export const createMockAppliance = (overrides: Partial = {}): Appliance => { + return { + appliance_id: 1, + name: 'Oven', + ...overrides, + }; }; \ No newline at end of file