Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e64426bd84 | ||
| 0ec4cd68d2 | |||
|
|
840516d2a3 | ||
| 59355c3eef | |||
| d024935fe9 | |||
|
|
5a5470634e | ||
| 392231ad63 | |||
|
|
4b1c896621 | ||
| 720920a51c | |||
|
|
460adb9506 | ||
| 7aa1f756a9 | |||
|
|
c484a8ca9b | ||
| 28d2c9f4ec | |||
|
|
ee253e9449 | ||
| b6c15e53d0 | |||
|
|
722162c2c3 | ||
| 02a76fe996 | |||
|
|
0ebb03a7ab | ||
| 748ac9e049 | |||
|
|
495edd621c | ||
| 4ffca19db6 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.8",
|
||||
"version": "0.7.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.8",
|
||||
"version": "0.7.18",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.7.8",
|
||||
"version": "0.7.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -1248,7 +1248,8 @@ INSERT INTO public.achievements (name, description, icon, points_value) VALUES
|
||||
('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20),
|
||||
('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5),
|
||||
('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10),
|
||||
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15)
|
||||
('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15),
|
||||
('First-Upload', 'Upload your first flyer.', 'upload-cloud', 25)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
@@ -2557,16 +2558,21 @@ DROP FUNCTION IF EXISTS public.log_new_flyer();
|
||||
CREATE OR REPLACE FUNCTION public.log_new_flyer()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.activity_log (action, display_text, icon, details)
|
||||
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
|
||||
-- The award_achievement function handles checking if the user already has it.
|
||||
IF NEW.uploaded_by IS NOT NULL THEN
|
||||
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
||||
VALUES (
|
||||
NEW.uploaded_by, -- Log the user who uploaded it
|
||||
'flyer_uploaded',
|
||||
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
|
||||
'file-text',
|
||||
jsonb_build_object(
|
||||
'flyer_id', NEW.flyer_id,
|
||||
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
|
||||
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
|
||||
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
|
||||
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id)
|
||||
)
|
||||
);
|
||||
RETURN NEW;
|
||||
|
||||
@@ -111,7 +111,7 @@ async function main() {
|
||||
|
||||
const flyerQuery = `
|
||||
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to)
|
||||
VALUES ('safeway-flyer.jpg', '/sample-assets/safeway-flyer.jpg', 'sample-checksum-123', ${storeMap.get('Safeway')}, $1, $2)
|
||||
VALUES ('safeway-flyer.jpg', 'https://example.com/flyer-images/safeway-flyer.jpg', 'a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0', ${storeMap.get('Safeway')}, $1, $2)
|
||||
RETURNING flyer_id;
|
||||
`;
|
||||
const flyerRes = await client.query<{ flyer_id: number }>(flyerQuery, [
|
||||
|
||||
@@ -482,6 +482,12 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockAddedItem);
|
||||
expect(db.shoppingRepo.addShoppingListItem).toHaveBeenCalledWith(
|
||||
listId,
|
||||
mockUserProfile.user.user_id,
|
||||
itemData,
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 on foreign key error when adding an item', async () => {
|
||||
@@ -519,6 +525,12 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedItem);
|
||||
expect(db.shoppingRepo.updateShoppingListItem).toHaveBeenCalledWith(
|
||||
itemId,
|
||||
mockUserProfile.user.user_id,
|
||||
updates,
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 if item to update is not found', async () => {
|
||||
@@ -554,6 +566,11 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.shoppingRepo.removeShoppingListItem).toHaveBeenCalledWith(
|
||||
101,
|
||||
mockUserProfile.user.user_id,
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 if item to delete is not found', async () => {
|
||||
|
||||
@@ -478,10 +478,16 @@ router.post(
|
||||
validateRequest(addShoppingListItemSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as AddShoppingListItemRequest;
|
||||
try {
|
||||
const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log);
|
||||
const newItem = await db.shoppingRepo.addShoppingListItem(
|
||||
params.listId,
|
||||
userProfile.user.user_id,
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
res.status(201).json(newItem);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
@@ -512,11 +518,13 @@ router.put(
|
||||
validateRequest(updateShoppingListItemSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as UpdateShoppingListItemRequest;
|
||||
try {
|
||||
const updatedItem = await db.shoppingRepo.updateShoppingListItem(
|
||||
params.itemId,
|
||||
userProfile.user.user_id,
|
||||
body,
|
||||
req.log,
|
||||
);
|
||||
@@ -541,10 +549,11 @@ router.delete(
|
||||
validateRequest(shoppingListItemIdSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteShoppingListItemRequest;
|
||||
try {
|
||||
await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log);
|
||||
await db.shoppingRepo.removeShoppingListItem(params.itemId, userProfile.user.user_id, req.log);
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
logger.error(
|
||||
|
||||
@@ -933,7 +933,7 @@ describe('API Client', () => {
|
||||
|
||||
it('logSearchQuery should send a POST request with query data', async () => {
|
||||
const queryData = createMockSearchQueryPayload({ query_text: 'apples', result_count: 10, was_successful: true });
|
||||
await apiClient.logSearchQuery(queryData);
|
||||
await apiClient.logSearchQuery(queryData as any);
|
||||
expect(capturedUrl?.pathname).toBe('/api/search/log');
|
||||
expect(capturedBody).toEqual(queryData);
|
||||
});
|
||||
@@ -960,7 +960,7 @@ describe('API Client', () => {
|
||||
result_count: 0,
|
||||
was_successful: false,
|
||||
});
|
||||
await apiClient.logSearchQuery(queryData);
|
||||
await apiClient.logSearchQuery(queryData as any);
|
||||
expect(logger.warn).toHaveBeenCalledWith('Failed to log search query', { error: apiError });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +203,11 @@ describe('Admin DB Service', () => {
|
||||
.mockRejectedValueOnce(new Error('DB Read Error'));
|
||||
|
||||
// The Promise.all should reject, and the function should re-throw the error
|
||||
await expect(adminRepo.getApplicationStats(mockLogger)).rejects.toThrow('DB Read Error');
|
||||
// The handleDbError function wraps the original error in a new one with a default message,
|
||||
// so we should test for that specific message.
|
||||
await expect(adminRepo.getApplicationStats(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve application statistics.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Database error in getApplicationStats',
|
||||
@@ -277,7 +281,7 @@ describe('Admin DB Service', () => {
|
||||
'Failed to get most frequent sale items.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
{ err: dbError, days: 30, limit: 10 },
|
||||
'Database error in getMostFrequentSaleItems',
|
||||
);
|
||||
});
|
||||
@@ -688,7 +692,9 @@ describe('Admin DB Service', () => {
|
||||
it('should re-throw a generic error if the database query fails for other reasons', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
await expect(adminRepo.updateUserRole('1', 'admin', mockLogger)).rejects.toThrow('DB Error');
|
||||
await expect(adminRepo.updateUserRole('1', 'admin', mockLogger)).rejects.toThrow(
|
||||
'Failed to update user role.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: '1', role: 'admin' },
|
||||
'Database error in updateUserRole',
|
||||
|
||||
@@ -249,6 +249,17 @@ describe('Budget DB Service', () => {
|
||||
expect(result).toEqual(mockUpdatedBudget);
|
||||
});
|
||||
|
||||
it('should prevent a user from updating a budget they do not own', async () => {
|
||||
// Arrange: Mock the query to return 0 rows affected
|
||||
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
// Act & Assert: Attempt to update with a different user ID should throw an error.
|
||||
await expect(
|
||||
budgetRepo.updateBudget(1, 'another-user', { name: 'Updated Groceries' }, mockLogger),
|
||||
).rejects.toThrow('Budget not found or user does not have permission to update.');
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error if no rows are updated', async () => {
|
||||
// Arrange: Mock the query to return 0 rows affected
|
||||
mockDb.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
@@ -82,15 +82,15 @@ describe('Deals DB Service', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should re-throw the error if the database query fails', async () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(dealsRepo.findBestPricesForWatchedItems('user-1', mockLogger)).rejects.toThrow(
|
||||
dbError,
|
||||
'Failed to find best prices for watched items.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
{ err: dbError, userId: 'user-1' },
|
||||
'Database error in findBestPricesForWatchedItems',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('Flyer DB Service', () => {
|
||||
ForeignKeyConstraintError,
|
||||
);
|
||||
await expect(flyerRepo.insertFlyerItems(999, itemsData, mockLogger)).rejects.toThrow(
|
||||
'The specified flyer does not exist.',
|
||||
'The specified flyer, category, master item, or product does not exist.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerId: 999 },
|
||||
@@ -285,10 +285,10 @@ describe('Flyer DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
// The implementation now re-throws the original error, so we should expect that.
|
||||
// The implementation wraps the error using handleDbError
|
||||
await expect(
|
||||
flyerRepo.insertFlyerItems(1, [{ item: 'Test' } as FlyerItemInsert], mockLogger),
|
||||
).rejects.toThrow(dbError);
|
||||
).rejects.toThrow('An unknown error occurred while inserting flyer items.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, flyerId: 1 },
|
||||
'Database error in insertFlyerItems',
|
||||
@@ -691,11 +691,7 @@ describe('Flyer DB Service', () => {
|
||||
);
|
||||
|
||||
await expect(flyerRepo.deleteFlyer(999, mockLogger)).rejects.toThrow(
|
||||
'Failed to delete flyer.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(NotFoundError), flyerId: 999 },
|
||||
'Database transaction error in deleteFlyer',
|
||||
'Flyer with ID 999 not found.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -714,4 +710,14 @@ describe('Flyer DB Service', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('deleteFlyer - Ownership Check', () => {
|
||||
it('should not delete flyer if the user does not own it', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(flyerRepo.deleteFlyer(1, mockLogger)).rejects.toThrow(
|
||||
'Flyer with ID 1 not found.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -97,16 +97,23 @@ export class FlyerRepository {
|
||||
flyerData.store_address, // $8
|
||||
flyerData.status, // $9
|
||||
flyerData.item_count, // $10
|
||||
flyerData.uploaded_by, // $11
|
||||
flyerData.uploaded_by ?? null, // $11
|
||||
];
|
||||
|
||||
const result = await this.db.query<Flyer>(query, values);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
const isChecksumError =
|
||||
error instanceof Error && error.message.includes('flyers_checksum_check');
|
||||
|
||||
handleDbError(error, logger, 'Database error in insertFlyer', { flyerData }, {
|
||||
uniqueMessage: 'A flyer with this checksum already exists.',
|
||||
fkMessage: 'The specified user or store for this flyer does not exist.',
|
||||
checkMessage: 'Invalid status provided for flyer.',
|
||||
// Provide a more specific message for the checksum constraint violation,
|
||||
// which is a common issue during seeding or testing with placeholder data.
|
||||
checkMessage: isChecksumError
|
||||
? 'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).'
|
||||
: 'Invalid status provided for flyer.',
|
||||
defaultMessage: 'Failed to insert flyer into database.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('Notification DB Service', () => {
|
||||
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
||||
).rejects.toThrow(ForeignKeyConstraintError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
{ err: dbError, notifications: notificationsToCreate },
|
||||
'Database error in createBulkNotifications',
|
||||
);
|
||||
});
|
||||
@@ -208,7 +208,7 @@ describe('Notification DB Service', () => {
|
||||
notificationRepo.createBulkNotifications(notificationsToCreate, mockLogger),
|
||||
).rejects.toThrow('Failed to create bulk notifications.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError },
|
||||
{ err: dbError, notifications: notificationsToCreate },
|
||||
'Database error in createBulkNotifications',
|
||||
);
|
||||
});
|
||||
@@ -264,6 +264,16 @@ describe('Notification DB Service', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('markNotificationAsRead - Ownership Check', () => {
|
||||
it('should not mark a notification as read if the user does not own it', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(notificationRepo.markNotificationAsRead(1, 'wrong-user', mockLogger)).rejects.toThrow(
|
||||
'Notification not found or user does not have permission.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllNotificationsAsRead', () => {
|
||||
it('should execute an UPDATE query to mark all notifications as read for a user', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 3 });
|
||||
|
||||
@@ -268,6 +268,17 @@ describe('Recipe DB Service', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('deleteRecipe - Ownership Check', () => {
|
||||
it('should not delete recipe if the user does not own it and is not an admin', async () => {
|
||||
mockQuery.mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(recipeRepo.deleteRecipe(1, 'wrong-user', false, mockLogger)).rejects.toThrow(
|
||||
'Recipe not found or user does not have permission to delete.',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('updateRecipe', () => {
|
||||
it('should execute an UPDATE query with the correct fields', async () => {
|
||||
@@ -442,10 +453,6 @@ describe('Recipe DB Service', () => {
|
||||
await expect(recipeRepo.forkRecipe('user-123', 1, mockLogger)).rejects.toThrow(
|
||||
'Recipe is not public and cannot be forked.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: 'user-123', originalRecipeId: 1 },
|
||||
'Database error in forkRecipe',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
|
||||
@@ -239,6 +239,10 @@ export class RecipeRepository {
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// Explicitly re-throw the "No fields" error before it gets caught by the generic handler.
|
||||
if (error instanceof Error && error.message === 'No fields provided to update.') {
|
||||
throw error;
|
||||
}
|
||||
handleDbError(error, logger, 'Database error in updateRecipe', { recipeId, userId, updates }, {
|
||||
defaultMessage: 'Failed to update recipe.',
|
||||
});
|
||||
|
||||
@@ -190,13 +190,14 @@ describe('Shopping DB Service', () => {
|
||||
|
||||
const result = await shoppingRepo.addShoppingListItem(
|
||||
1,
|
||||
'user-1',
|
||||
{ customItemName: 'Custom Item' },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_list_items'),
|
||||
[1, null, 'Custom Item'],
|
||||
[1, null, 'Custom Item', 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
@@ -205,11 +206,11 @@ describe('Shopping DB Service', () => {
|
||||
const mockItem = createMockShoppingListItem({ master_item_id: 123 });
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
|
||||
|
||||
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123 }, mockLogger);
|
||||
const result = await shoppingRepo.addShoppingListItem(1, 'user-1', { masterItemId: 123 }, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_list_items'),
|
||||
[1, 123, null],
|
||||
[1, 123, null, 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
@@ -223,19 +224,20 @@ describe('Shopping DB Service', () => {
|
||||
|
||||
const result = await shoppingRepo.addShoppingListItem(
|
||||
1,
|
||||
'user-1',
|
||||
{ masterItemId: 123, customItemName: 'Organic Apples' },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO public.shopping_list_items'),
|
||||
[1, 123, 'Organic Apples'],
|
||||
[1, 123, 'Organic Apples', 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
|
||||
it('should throw an error if both masterItemId and customItemName are missing', async () => {
|
||||
await expect(shoppingRepo.addShoppingListItem(1, {}, mockLogger)).rejects.toThrow(
|
||||
await expect(shoppingRepo.addShoppingListItem(1, 'user-1', {}, mockLogger)).rejects.toThrow(
|
||||
'Either masterItemId or customItemName must be provided.',
|
||||
);
|
||||
});
|
||||
@@ -244,19 +246,19 @@ describe('Shopping DB Service', () => {
|
||||
const dbError = new Error('violates foreign key constraint');
|
||||
(dbError as Error & { code: string }).code = '23503';
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(
|
||||
shoppingRepo.addShoppingListItem(999, { masterItemId: 999 }, mockLogger),
|
||||
).rejects.toThrow('Referenced list or item does not exist.');
|
||||
await expect(shoppingRepo.addShoppingListItem(999, 'user-1', { masterItemId: 999 }, mockLogger)).rejects.toThrow(
|
||||
'Referenced list or item does not exist.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(
|
||||
shoppingRepo.addShoppingListItem(1, { customItemName: 'Test' }, mockLogger),
|
||||
shoppingRepo.addShoppingListItem(1, 'user-1', { customItemName: 'Test' }, mockLogger),
|
||||
).rejects.toThrow('Failed to add item to shopping list.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, listId: 1, item: { customItemName: 'Test' } },
|
||||
{ err: dbError, listId: 1, userId: 'user-1', item: { customItemName: 'Test' } },
|
||||
'Database error in addShoppingListItem',
|
||||
);
|
||||
});
|
||||
@@ -269,13 +271,14 @@ describe('Shopping DB Service', () => {
|
||||
|
||||
const result = await shoppingRepo.updateShoppingListItem(
|
||||
1,
|
||||
'user-1',
|
||||
{ is_purchased: true },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.shopping_list_items SET is_purchased = $1 WHERE shopping_list_item_id = $2 RETURNING *',
|
||||
[true, 1],
|
||||
expect.stringContaining('UPDATE public.shopping_list_items sli'),
|
||||
[true, 1, 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
@@ -285,11 +288,11 @@ describe('Shopping DB Service', () => {
|
||||
const mockItem = createMockShoppingListItem({ shopping_list_item_id: 1, ...updates });
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
|
||||
|
||||
const result = await shoppingRepo.updateShoppingListItem(1, updates, mockLogger);
|
||||
const result = await shoppingRepo.updateShoppingListItem(1, 'user-1', updates, mockLogger);
|
||||
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'UPDATE public.shopping_list_items SET quantity = $1, is_purchased = $2, notes = $3 WHERE shopping_list_item_id = $4 RETURNING *',
|
||||
[updates.quantity, updates.is_purchased, updates.notes, 1],
|
||||
expect.stringContaining('UPDATE public.shopping_list_items sli'),
|
||||
[updates.quantity, updates.is_purchased, updates.notes, 1, 'user-1'],
|
||||
);
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
@@ -297,13 +300,13 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw an error if the item to update is not found', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'UPDATE' });
|
||||
await expect(
|
||||
shoppingRepo.updateShoppingListItem(999, { quantity: 5 }, mockLogger),
|
||||
shoppingRepo.updateShoppingListItem(999, 'user-1', { quantity: 5 }, mockLogger),
|
||||
).rejects.toThrow('Shopping list item not found.');
|
||||
});
|
||||
|
||||
it('should throw an error if no valid fields are provided to update', async () => {
|
||||
// The function should throw before even querying the database.
|
||||
await expect(shoppingRepo.updateShoppingListItem(1, {}, mockLogger)).rejects.toThrow(
|
||||
await expect(shoppingRepo.updateShoppingListItem(1, 'user-1', {}, mockLogger)).rejects.toThrow(
|
||||
'No valid fields to update.',
|
||||
);
|
||||
});
|
||||
@@ -312,28 +315,39 @@ describe('Shopping DB Service', () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(
|
||||
shoppingRepo.updateShoppingListItem(1, { is_purchased: true }, mockLogger),
|
||||
shoppingRepo.updateShoppingListItem(1, 'user-1', { is_purchased: true }, mockLogger),
|
||||
).rejects.toThrow('Failed to update shopping list item.');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, itemId: 1, updates: { is_purchased: true } },
|
||||
{ err: dbError, itemId: 1, userId: 'user-1', updates: { is_purchased: true } },
|
||||
'Database error in updateShoppingListItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateShoppingListItem - Ownership Check', () => {
|
||||
it('should not update an item if the user does not own the shopping list', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(
|
||||
shoppingRepo.updateShoppingListItem(1, 'wrong-user', { is_purchased: true }, mockLogger),
|
||||
).rejects.toThrow('Shopping list item not found.');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('removeShoppingListItem', () => {
|
||||
it('should delete an item if rowCount is 1', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).resolves.toBeUndefined();
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, 'user-1', mockLogger)).resolves.toBeUndefined();
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||
'DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
|
||||
[1],
|
||||
expect.stringContaining('DELETE FROM public.shopping_list_items sli'),
|
||||
[1, 'user-1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if no rows are deleted (item not found)', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
|
||||
await expect(shoppingRepo.removeShoppingListItem(999, mockLogger)).rejects.toThrow(
|
||||
await expect(shoppingRepo.removeShoppingListItem(999, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Shopping list item not found.',
|
||||
);
|
||||
});
|
||||
@@ -341,15 +355,25 @@ describe('Shopping DB Service', () => {
|
||||
it('should throw a generic error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockPoolInstance.query.mockRejectedValue(dbError);
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, mockLogger)).rejects.toThrow(
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Failed to remove item from shopping list.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, itemId: 1 },
|
||||
{ err: dbError, itemId: 1, userId: 'user-1' },
|
||||
'Database error in removeShoppingListItem',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('removeShoppingListItem - Ownership Check', () => {
|
||||
it('should not remove an item if the user does not own the shopping list', async () => {
|
||||
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(shoppingRepo.removeShoppingListItem(1, 'wrong-user', mockLogger)).rejects.toThrow(
|
||||
'Shopping list item not found.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('completeShoppingList', () => {
|
||||
it('should call the complete_shopping_list database function', async () => {
|
||||
|
||||
@@ -156,6 +156,7 @@ export class ShoppingRepository {
|
||||
*/
|
||||
async addShoppingListItem(
|
||||
listId: number,
|
||||
userId: string,
|
||||
item: { masterItemId?: number; customItemName?: string },
|
||||
logger: Logger,
|
||||
): Promise<ShoppingListItem> {
|
||||
@@ -165,13 +166,29 @@ export class ShoppingRepository {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.db.query<ShoppingListItem>(
|
||||
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
|
||||
[listId, item.masterItemId ?? null, item.customItemName ?? null],
|
||||
);
|
||||
const query = `
|
||||
INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name)
|
||||
SELECT $1, $2, $3
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $4
|
||||
)
|
||||
RETURNING *;
|
||||
`;
|
||||
const res = await this.db.query<ShoppingListItem>(query, [
|
||||
listId,
|
||||
item.masterItemId ?? null,
|
||||
item.customItemName ?? null,
|
||||
userId,
|
||||
]);
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list not found or user does not have permission.');
|
||||
}
|
||||
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Database error in addShoppingListItem', { listId, item }, {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
handleDbError(error, logger, 'Database error in addShoppingListItem', { listId, userId, item }, {
|
||||
fkMessage: 'Referenced list or item does not exist.',
|
||||
checkMessage: 'Shopping list item must have a master item or a custom name.',
|
||||
defaultMessage: 'Failed to add item to shopping list.',
|
||||
@@ -183,19 +200,23 @@ export class ShoppingRepository {
|
||||
* Removes an item from a shopping list.
|
||||
* @param itemId The ID of the shopping list item to remove.
|
||||
*/
|
||||
async removeShoppingListItem(itemId: number, logger: Logger): Promise<void> {
|
||||
async removeShoppingListItem(itemId: number, userId: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
'DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
|
||||
[itemId],
|
||||
);
|
||||
// The patch requested this specific error handling.
|
||||
const query = `
|
||||
DELETE FROM public.shopping_list_items sli
|
||||
WHERE sli.shopping_list_item_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.shopping_lists sl
|
||||
WHERE sl.shopping_list_id = sli.shopping_list_id AND sl.user_id = $2
|
||||
);
|
||||
`;
|
||||
const res = await this.db.query(query, [itemId, userId]);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list item not found.');
|
||||
throw new NotFoundError('Shopping list item not found or user does not have permission.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
handleDbError(error, logger, 'Database error in removeShoppingListItem', { itemId }, {
|
||||
handleDbError(error, logger, 'Database error in removeShoppingListItem', { itemId, userId }, {
|
||||
defaultMessage: 'Failed to remove item from shopping list.',
|
||||
});
|
||||
}
|
||||
@@ -312,6 +333,7 @@ export class ShoppingRepository {
|
||||
*/
|
||||
async updateShoppingListItem(
|
||||
itemId: number,
|
||||
userId: string,
|
||||
updates: Partial<ShoppingListItem>,
|
||||
logger: Logger,
|
||||
): Promise<ShoppingListItem> {
|
||||
@@ -341,10 +363,19 @@ export class ShoppingRepository {
|
||||
}
|
||||
|
||||
values.push(itemId);
|
||||
const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE shopping_list_item_id = $${valueIndex} RETURNING *`;
|
||||
values.push(userId);
|
||||
|
||||
const query = `
|
||||
UPDATE public.shopping_list_items sli
|
||||
SET ${setClauses.join(', ')}
|
||||
FROM public.shopping_lists sl
|
||||
WHERE sli.shopping_list_item_id = $${valueIndex}
|
||||
AND sli.shopping_list_id = sl.shopping_list_id
|
||||
AND sl.user_id = $${valueIndex + 1}
|
||||
RETURNING sli.*;
|
||||
`;
|
||||
|
||||
const res = await this.db.query<ShoppingListItem>(query, values);
|
||||
// The patch requested this specific error handling.
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list item not found.');
|
||||
}
|
||||
@@ -357,7 +388,7 @@ export class ShoppingRepository {
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
handleDbError(error, logger, 'Database error in updateShoppingListItem', { itemId, updates }, {
|
||||
handleDbError(error, logger, 'Database error in updateShoppingListItem', { itemId, userId, updates }, {
|
||||
defaultMessage: 'Failed to update shopping list item.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,9 +233,7 @@ describe('User DB Service', () => {
|
||||
}
|
||||
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
`Attempted to create a user with an existing email: exists@example.com`,
|
||||
);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(`Attempted to create a user with an existing email: exists@example.com`);
|
||||
});
|
||||
|
||||
it('should throw an error if profile is not found after user creation', async () => {
|
||||
@@ -836,9 +834,7 @@ describe('User DB Service', () => {
|
||||
);
|
||||
|
||||
// Act & Assert: The outer function catches the NotFoundError and re-throws it.
|
||||
await expect(exportUserData('123', mockLogger)).rejects.toThrow(
|
||||
'Failed to export user data.',
|
||||
);
|
||||
await expect(exportUserData('123', mockLogger)).rejects.toThrow('Profile not found');
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import type { Logger } from 'pino';
|
||||
import { NotFoundError, handleDbError } from './errors.db';
|
||||
import { NotFoundError, handleDbError, UniqueConstraintError } from './errors.db';
|
||||
import {
|
||||
Profile,
|
||||
MasterGroceryItem,
|
||||
@@ -127,6 +127,12 @@ export class UserRepository {
|
||||
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
|
||||
return fullUserProfile;
|
||||
}).catch((error) => {
|
||||
// Specific handling for unique constraint violation on user creation
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === '23505') {
|
||||
logger.warn(`Attempted to create a user with an existing email: ${email}`);
|
||||
throw new UniqueConstraintError('A user with this email address already exists.');
|
||||
}
|
||||
// Fallback to generic handler for all other errors
|
||||
handleDbError(error, logger, 'Error during createUser transaction', { email }, {
|
||||
uniqueMessage: 'A user with this email address already exists.',
|
||||
defaultMessage: 'Failed to create user in database.',
|
||||
|
||||
@@ -165,7 +165,9 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
const flyerRes = await getPool().query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
|
||||
VALUES ($1, 'admin-test.jpg', 'http://test.com/img.jpg', 1, $2) RETURNING flyer_id`,
|
||||
[testStoreId, `checksum-${Date.now()}-${Math.random()}`],
|
||||
// The checksum must be a unique 64-character string to satisfy the DB constraint.
|
||||
// We generate a dynamic string and pad it to 64 characters.
|
||||
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],
|
||||
);
|
||||
const flyerId = flyerRes.rows[0].flyer_id;
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// Act 2: Poll for the job status until it completes.
|
||||
let jobStatus;
|
||||
const maxRetries = 30; // Poll for up to 90 seconds (30 * 3s)
|
||||
const maxRetries = 60; // Poll for up to 180 seconds (60 * 3s)
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
console.log(`Polling attempt ${i + 1}...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
||||
@@ -149,12 +149,12 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
// Act & Assert
|
||||
await runBackgroundProcessingTest(authUser, token);
|
||||
}, 120000); // Increase timeout to 120 seconds for this long-running test
|
||||
}, 240000); // Increase timeout to 240 seconds for this long-running test
|
||||
|
||||
it('should successfully process a flyer for an ANONYMOUS user via the background queue', async () => {
|
||||
// Act & Assert: Call the test helper without a user or token.
|
||||
await runBackgroundProcessingTest();
|
||||
}, 120000); // Increase timeout to 120 seconds for this long-running test
|
||||
}, 240000); // Increase timeout to 240 seconds for this long-running test
|
||||
|
||||
it(
|
||||
'should strip EXIF data from uploaded JPEG images during processing',
|
||||
@@ -238,7 +238,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
expect(exifResult.tags).toEqual({});
|
||||
expect(exifResult.tags.Software).toBeUndefined();
|
||||
},
|
||||
120000,
|
||||
240000,
|
||||
);
|
||||
|
||||
it(
|
||||
@@ -322,6 +322,6 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// The `exif` property should be undefined after the fix.
|
||||
expect(savedImageMetadata.exif).toBeUndefined();
|
||||
},
|
||||
120000,
|
||||
240000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
||||
const flyerRes = await getPool().query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
|
||||
VALUES ($1, 'integration-test.jpg', 'http://test.com/img.jpg', 1, $2) RETURNING flyer_id`,
|
||||
[storeId, `checksum-${Date.now()}`],
|
||||
[storeId, `checksum-${Date.now()}`.padEnd(64, '0')],
|
||||
);
|
||||
createdFlyerId = flyerRes.rows[0].flyer_id;
|
||||
|
||||
|
||||
@@ -81,12 +81,17 @@ describe('Gamification Flow Integration Test', () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!jobStatus) {
|
||||
console.error('[DEBUG] Gamification test job timed out: No job status received.');
|
||||
throw new Error('Gamification test job timed out: No job status received.');
|
||||
}
|
||||
|
||||
// --- Assert 1: Verify the job completed successfully ---
|
||||
if (jobStatus?.state === 'failed') {
|
||||
console.error('[DEBUG] Gamification test job failed:', jobStatus.failedReason);
|
||||
}
|
||||
expect(jobStatus?.state).toBe('completed');
|
||||
|
||||
const flyerId = jobStatus?.returnValue?.flyerId;
|
||||
expect(flyerId).toBeTypeOf('number');
|
||||
createdFlyerIds.push(flyerId); // Track for cleanup
|
||||
|
||||
Reference in New Issue
Block a user