moar unit test !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m46s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m46s
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
|
||||
|
||||
907
src/services/apiClient.test.ts
Normal file
907
src/services/apiClient.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user