Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce82034b9d | ||
| 4528da2934 | |||
|
|
146d4c1351 | ||
| 88625706f4 | |||
|
|
e395faed30 | ||
| e8f8399896 | |||
|
|
ac0115af2b | ||
| f24b15f19b | |||
|
|
e64426bd84 | ||
| 0ec4cd68d2 | |||
|
|
840516d2a3 | ||
| 59355c3eef | |||
| d024935fe9 | |||
|
|
5a5470634e | ||
| 392231ad63 | |||
|
|
4b1c896621 | ||
| 720920a51c | |||
|
|
460adb9506 | ||
| 7aa1f756a9 | |||
|
|
c484a8ca9b | ||
| 28d2c9f4ec | |||
|
|
ee253e9449 | ||
| b6c15e53d0 | |||
|
|
722162c2c3 | ||
| 02a76fe996 | |||
|
|
0ebb03a7ab | ||
| 748ac9e049 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.22",
|
||||
"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.9",
|
||||
"version": "0.7.22",
|
||||
"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;
|
||||
|
||||
33
src/components/Dashboard.tsx
Normal file
33
src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { RecipeSuggester } from '../components/RecipeSuggester';
|
||||
import { FlyerCountDisplay } from '../components/FlyerCountDisplay';
|
||||
import { Leaderboard } from '../components/Leaderboard';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content Area */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Recipe Suggester Section */}
|
||||
<RecipeSuggester />
|
||||
|
||||
{/* Other Dashboard Widgets */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Your Flyers</h2>
|
||||
<FlyerCountDisplay />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Area */}
|
||||
<div className="space-y-6">
|
||||
<Leaderboard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
80
src/components/RecipeSuggester.tsx
Normal file
80
src/components/RecipeSuggester.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
// src/components/RecipeSuggester.tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { suggestRecipe } from '../services/apiClient';
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
export const RecipeSuggester: React.FC = () => {
|
||||
const [ingredients, setIngredients] = useState<string>('');
|
||||
const [suggestion, setSuggestion] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuggestion(null);
|
||||
|
||||
const ingredientList = ingredients.split(',').map(item => item.trim()).filter(Boolean);
|
||||
|
||||
if (ingredientList.length === 0) {
|
||||
setError('Please enter at least one ingredient.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await suggestRecipe(ingredientList);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to get suggestion.');
|
||||
}
|
||||
|
||||
setSuggestion(data.suggestion);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||
logger.error({ error: err }, 'Failed to fetch recipe suggestion.');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [ingredients]);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Get a Recipe Suggestion</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Enter some ingredients you have, separated by commas.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="ingredients-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ingredients:</label>
|
||||
<input
|
||||
id="ingredients-input"
|
||||
type="text"
|
||||
value={ingredients}
|
||||
onChange={(e) => setIngredients(e.target.value)}
|
||||
placeholder="e.g., chicken, rice, broccoli"
|
||||
disabled={isLoading}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm p-2 border"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={isLoading} className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors">
|
||||
{isLoading ? 'Getting suggestion...' : 'Suggest a Recipe'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/50 text-red-700 dark:text-red-200 rounded-md text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{suggestion && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<h5 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Recipe Suggestion</h5>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,8 +482,8 @@ describe('Passport Configuration', () => {
|
||||
const mockReq: Partial<Request> = {
|
||||
// An object that is not a valid UserProfile (e.g., missing 'role')
|
||||
user: {
|
||||
user_id: 'invalid-user-id',
|
||||
} as any,
|
||||
user: { user_id: 'invalid-user-id' }, // Missing 'role' property
|
||||
} as unknown as UserProfile, // Cast to UserProfile to satisfy req.user type, but it's intentionally malformed
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { aiService } from '../services/aiService.server';
|
||||
import passport from './passport.routes';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
|
||||
|
||||
@@ -28,6 +30,12 @@ const byIngredientAndTagSchema = z.object({
|
||||
|
||||
const recipeIdParamsSchema = numericIdParam('recipeId');
|
||||
|
||||
const suggestRecipeSchema = z.object({
|
||||
body: z.object({
|
||||
ingredients: z.array(z.string().min(1)).nonempty('At least one ingredient is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale.
|
||||
*/
|
||||
@@ -121,4 +129,31 @@ router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res,
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/recipes/suggest - Generates a simple recipe suggestion from a list of ingredients.
|
||||
* This is a protected endpoint.
|
||||
*/
|
||||
router.post(
|
||||
'/suggest',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(suggestRecipeSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { body } = req as unknown as z.infer<typeof suggestRecipeSchema>;
|
||||
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
|
||||
|
||||
if (!suggestion) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: 'AI service is currently unavailable or failed to generate a suggestion.' });
|
||||
}
|
||||
|
||||
res.json({ suggestion });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error generating recipe suggestion');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/services/aiService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||
import type { Logger } from 'pino';
|
||||
import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
DuplicateFlyerError,
|
||||
type RawFlyerItem,
|
||||
} from './aiService.server';
|
||||
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
|
||||
import { createMockMasterGroceryItem, createMockFlyer } from '../tests/utils/mockFactories';
|
||||
import { ValidationError } from './db/errors.db';
|
||||
import { AiFlyerDataSchema } from '../types/ai';
|
||||
|
||||
@@ -319,41 +320,172 @@ describe('AI Service (Server)', () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
|
||||
const quotaError1 = new Error('Quota exhausted for model 1');
|
||||
const quotaError2 = new Error('429 Too Many Requests for model 2');
|
||||
const quotaError3 = new Error('RESOURCE_EXHAUSTED for model 3');
|
||||
// Access private property for testing purposes to ensure test stays in sync with implementation
|
||||
const models = (serviceWithFallback as any).models as string[];
|
||||
const errors = models.map((model, i) => new Error(`Error for model ${model} (${i})`));
|
||||
const lastError = errors[errors.length - 1];
|
||||
|
||||
mockGenerateContent
|
||||
.mockRejectedValueOnce(quotaError1)
|
||||
.mockRejectedValueOnce(quotaError2)
|
||||
.mockRejectedValueOnce(quotaError3);
|
||||
// Dynamically setup mocks
|
||||
errors.forEach((err) => {
|
||||
mockGenerateContent.mockRejectedValueOnce(err);
|
||||
});
|
||||
|
||||
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
|
||||
|
||||
// Act & Assert
|
||||
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
|
||||
quotaError3,
|
||||
lastError,
|
||||
);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
|
||||
model: 'gemini-2.5-flash',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, { // The third model in the list is 'gemini-2.5-flash-lite'
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
...request,
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(models.length);
|
||||
|
||||
models.forEach((model, index) => {
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
|
||||
model: model,
|
||||
...request,
|
||||
});
|
||||
});
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ lastError: quotaError3 },
|
||||
{ lastError },
|
||||
'[AIService Adapter] All AI models failed. Throwing last known error.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use lite models and throw the last error if all lite models fail', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const { logger } = await import('./logger.server');
|
||||
// We instantiate with the real logger to test the production fallback logic
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
|
||||
// Access private property for testing purposes
|
||||
const modelsLite = (serviceWithFallback as any).models_lite as string[];
|
||||
const errors = modelsLite.map((model, i) => new Error(`Error for lite model ${model} (${i})`));
|
||||
const lastError = errors[errors.length - 1];
|
||||
|
||||
// Dynamically setup mocks
|
||||
errors.forEach((err) => {
|
||||
mockGenerateContent.mockRejectedValueOnce(err);
|
||||
});
|
||||
|
||||
const request = {
|
||||
contents: [{ parts: [{ text: 'test prompt' }] }],
|
||||
useLiteModels: true, // This is the key to trigger the lite model list
|
||||
};
|
||||
// The adapter strips `useLiteModels` before calling the underlying client,
|
||||
// so we prepare the expected request shape for our assertions.
|
||||
const { useLiteModels, ...apiReq } = request;
|
||||
|
||||
// Act & Assert
|
||||
// Expect the entire operation to reject with the error from the very last model attempt.
|
||||
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
|
||||
lastError,
|
||||
);
|
||||
|
||||
// Verify that all lite models were attempted in the correct order.
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(modelsLite.length);
|
||||
|
||||
modelsLite.forEach((model, index) => {
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
|
||||
model: model,
|
||||
...apiReq,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should dynamically try the next model if the first one fails and succeed if the second one works', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
|
||||
// Access private property for testing purposes
|
||||
const models = (serviceWithFallback as any).models as string[];
|
||||
// Ensure we have enough models to test fallback
|
||||
expect(models.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const error1 = new Error('Quota exceeded for model 1');
|
||||
const successResponse = { text: 'Success', candidates: [] };
|
||||
|
||||
mockGenerateContent
|
||||
.mockRejectedValueOnce(error1)
|
||||
.mockResolvedValueOnce(successResponse);
|
||||
|
||||
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
|
||||
|
||||
// Act
|
||||
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(successResponse);
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
|
||||
model: models[0],
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
|
||||
model: models[1],
|
||||
...request,
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Model '${models[0]}' failed`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on a 429 error and succeed on the next model', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
const models = (serviceWithFallback as any).models as string[];
|
||||
|
||||
const retriableError = new Error('429 Too Many Requests');
|
||||
const successResponse = { text: 'Success from second model', candidates: [] };
|
||||
|
||||
mockGenerateContent
|
||||
.mockRejectedValueOnce(retriableError)
|
||||
.mockResolvedValueOnce(successResponse);
|
||||
|
||||
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
|
||||
|
||||
// Act
|
||||
const result = await (serviceWithFallback as any).aiClient.generateContent(request);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(successResponse);
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { model: models[0], ...request });
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { model: models[1], ...request });
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(`Model '${models[0]}' failed due to quota/rate limit.`));
|
||||
});
|
||||
|
||||
it('should fail immediately on a 400 Bad Request error without retrying', async () => {
|
||||
// Arrange
|
||||
const { AIService } = await import('./aiService.server');
|
||||
const { logger } = await import('./logger.server');
|
||||
const serviceWithFallback = new AIService(logger);
|
||||
const models = (serviceWithFallback as any).models as string[];
|
||||
|
||||
const nonRetriableError = new Error('400 Bad Request: Invalid input');
|
||||
mockGenerateContent.mockRejectedValueOnce(nonRetriableError);
|
||||
|
||||
const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };
|
||||
|
||||
// Act & Assert
|
||||
await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(nonRetriableError);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({ model: models[0], ...request });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: nonRetriableError },
|
||||
`[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
|
||||
);
|
||||
// Ensure it didn't log a warning about trying the next model
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Trying next model'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractItemsFromReceiptImage', () => {
|
||||
@@ -783,7 +915,7 @@ describe('AI Service (Server)', () => {
|
||||
} as UserProfile;
|
||||
|
||||
it('should throw DuplicateFlyerError if flyer already exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99 } as any);
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99, checksum: 'checksum123', file_name: 'test.pdf', image_url: '/flyer-images/test.pdf', icon_url: '/flyer-images/icons/test.webp', store_id: 1, status: 'processed', item_count: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
||||
|
||||
await expect(
|
||||
aiServiceInstance.enqueueFlyerProcessing(
|
||||
@@ -798,7 +930,7 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
it('should enqueue job with user address if profile exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job123' } as any);
|
||||
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job123' } as unknown as Job);
|
||||
|
||||
const result = await aiServiceInstance.enqueueFlyerProcessing(
|
||||
mockFile,
|
||||
@@ -821,7 +953,7 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
it('should enqueue job without address if profile is missing', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job456' } as any);
|
||||
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job456' } as unknown as Job);
|
||||
|
||||
await aiServiceInstance.enqueueFlyerProcessing(
|
||||
mockFile,
|
||||
@@ -850,7 +982,7 @@ describe('AI Service (Server)', () => {
|
||||
const mockProfile = { user: { user_id: 'u1' } } as UserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default success mocks
|
||||
// Default success mocks. Use createMockFlyer for a more complete mock.
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
|
||||
vi.mocked(createFlyerAndItems).mockResolvedValue({
|
||||
@@ -887,7 +1019,7 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
|
||||
it('should throw DuplicateFlyerError if checksum exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 55 } as any);
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 55 }));
|
||||
const body = { checksum: 'dup-sum' };
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -62,6 +62,7 @@ interface IAiClient {
|
||||
generateContent(request: {
|
||||
contents: Content[];
|
||||
tools?: Tool[];
|
||||
useLiteModels?: boolean;
|
||||
}): Promise<GenerateContentResponse>;
|
||||
}
|
||||
|
||||
@@ -93,7 +94,8 @@ export class AIService {
|
||||
// The fallback list is ordered by preference (speed/cost vs. power).
|
||||
// We try the fastest models first, then the more powerful 'pro' model as a high-quality fallback,
|
||||
// and finally the 'lite' model as a last resort.
|
||||
private readonly models = [ 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
|
||||
private readonly models = [ 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemma-3-27b', 'gemma-3-12b'];
|
||||
private readonly models_lite = ["gemma-3-4b", "gemma-3-2b", "gemma-3-1b"];
|
||||
|
||||
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
|
||||
this.logger = logger;
|
||||
@@ -156,7 +158,9 @@ export class AIService {
|
||||
throw new Error('AIService.generateContent requires at least one content element.');
|
||||
}
|
||||
|
||||
return this._generateWithFallback(genAI, request);
|
||||
const { useLiteModels, ...apiReq } = request;
|
||||
const models = useLiteModels ? this.models_lite : this.models;
|
||||
return this._generateWithFallback(genAI, apiReq, models);
|
||||
},
|
||||
}
|
||||
: {
|
||||
@@ -194,10 +198,11 @@ export class AIService {
|
||||
private async _generateWithFallback(
|
||||
genAI: GoogleGenAI,
|
||||
request: { contents: Content[]; tools?: Tool[] },
|
||||
models: string[] = this.models,
|
||||
): Promise<GenerateContentResponse> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const modelName of this.models) {
|
||||
for (const modelName of models) {
|
||||
try {
|
||||
this.logger.info(
|
||||
`[AIService Adapter] Attempting to generate content with model: ${modelName}`,
|
||||
@@ -668,6 +673,33 @@ export class AIService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple recipe suggestion based on a list of ingredients.
|
||||
* Uses the 'lite' models for faster/cheaper generation.
|
||||
* @param ingredients List of available ingredients.
|
||||
* @param logger Logger instance.
|
||||
* @returns The recipe suggestion text.
|
||||
*/
|
||||
async generateRecipeSuggestion(
|
||||
ingredients: string[],
|
||||
logger: Logger = this.logger,
|
||||
): Promise<string | null> {
|
||||
const prompt = `Suggest a simple recipe using these ingredients: ${ingredients.join(', ')}. Keep it brief.`;
|
||||
|
||||
try {
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
useLiteModels: true,
|
||||
}),
|
||||
);
|
||||
return result.text || null;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to generate recipe suggestion');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SERVER-SIDE FUNCTION
|
||||
* Uses Google Maps grounding to find nearby stores and plan a shopping trip.
|
||||
|
||||
@@ -543,6 +543,13 @@ describe('API Client', () => {
|
||||
await apiClient.deleteRecipe(recipeId);
|
||||
expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}`);
|
||||
});
|
||||
|
||||
it('suggestRecipe should send a POST request with ingredients', async () => {
|
||||
const ingredients = ['chicken', 'rice'];
|
||||
await apiClient.suggestRecipe(ingredients);
|
||||
expect(capturedUrl?.pathname).toBe('/api/recipes/suggest');
|
||||
expect(capturedBody).toEqual({ ingredients });
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Profile and Settings API Functions', () => {
|
||||
|
||||
@@ -636,6 +636,20 @@ export const addRecipeComment = (
|
||||
): Promise<Response> =>
|
||||
authedPost(`/recipes/${recipeId}/comments`, { content, parentCommentId }, { tokenOverride });
|
||||
|
||||
/**
|
||||
* Requests a simple recipe suggestion from the AI based on a list of ingredients.
|
||||
* @param ingredients An array of ingredient strings.
|
||||
* @param tokenOverride Optional token for testing.
|
||||
* @returns A promise that resolves to the API response containing the suggestion.
|
||||
*/
|
||||
export const suggestRecipe = (
|
||||
ingredients: string[],
|
||||
tokenOverride?: string,
|
||||
): Promise<Response> => {
|
||||
// This is a protected endpoint, so we use authedPost.
|
||||
return authedPost('/recipes/suggest', { ingredients }, { tokenOverride });
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a recipe.
|
||||
* @param recipeId The ID of the recipe to delete.
|
||||
|
||||
@@ -17,6 +17,11 @@ describe('AuthService', () => {
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
password_hash: 'hashed-password',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
refresh_token: null,
|
||||
};
|
||||
const mockUserProfile: UserProfile = {
|
||||
user: mockUser,
|
||||
@@ -205,7 +210,7 @@ describe('AuthService', () => {
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('should process password reset for existing user', async () => {
|
||||
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser);
|
||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');
|
||||
|
||||
const result = await authService.resetPassword('test@example.com', reqLog);
|
||||
@@ -284,7 +289,7 @@ describe('AuthService', () => {
|
||||
|
||||
describe('getUserByRefreshToken', () => {
|
||||
it('should return user profile if token exists', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
|
||||
const result = await authService.getUserByRefreshToken('valid-token', reqLog);
|
||||
@@ -318,7 +323,7 @@ describe('AuthService', () => {
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it('should return new access token if user found', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,44 +315,65 @@ 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(
|
||||
'Shopping list item not found.',
|
||||
await expect(shoppingRepo.removeShoppingListItem(999, 'user-1', mockLogger)).rejects.toThrow(
|
||||
'Shopping list item not found or user does not have permission.',
|
||||
);
|
||||
});
|
||||
|
||||
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 or user does not have permission.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,8 +73,12 @@ describe('User DB Service', () => {
|
||||
const mockUser = {
|
||||
user_id: '123',
|
||||
email: 'test@example.com',
|
||||
password_hash: 'some-hash',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
refresh_token: null,
|
||||
};
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser] });
|
||||
|
||||
@@ -233,9 +237,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 +838,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.',
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('FlyerAiProcessor', () => {
|
||||
valid_to: null,
|
||||
store_address: null,
|
||||
};
|
||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse as any);
|
||||
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
|
||||
const { logger } = await import('./logger.server');
|
||||
|
||||
const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
|
||||
|
||||
@@ -164,8 +164,10 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
beforeEach(async () => {
|
||||
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()}`],
|
||||
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-test.jpg', 1, $2) RETURNING flyer_id`,
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/tests/integration/flyer-processing.integration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import fs from 'node:fs/promises';
|
||||
@@ -8,7 +8,7 @@ import * as db from '../../services/db/index.db';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import { generateFileChecksum } from '../../utils/checksum';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import type { UserProfile } from '../../types';
|
||||
import type { UserProfile, ExtractedFlyerItem } from '../../types';
|
||||
import { createAndLoginUser } from '../utils/testHelpers';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
@@ -16,12 +16,16 @@ import piexif from 'piexifjs';
|
||||
import exifParser from 'exif-parser';
|
||||
import sharp from 'sharp';
|
||||
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
// Import the mocked service to control its behavior in tests.
|
||||
import { aiService } from '../../services/aiService.server';
|
||||
|
||||
describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const createdUserIds: string[] = [];
|
||||
const createdFlyerIds: number[] = [];
|
||||
@@ -29,6 +33,23 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
// This setup is now simpler as the worker handles fetching master items.
|
||||
// Setup default mock response for AI service
|
||||
const mockItems: ExtractedFlyerItem[] = [
|
||||
{
|
||||
item: 'Mocked Integration Item',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: 'each',
|
||||
category_name: 'Mock Category',
|
||||
},
|
||||
];
|
||||
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({
|
||||
store_name: 'Mock Store',
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
store_address: null,
|
||||
items: mockItems,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -80,7 +101,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 +170,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',
|
||||
@@ -219,7 +240,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
console.error('[DEBUG] EXIF test job failed:', jobStatus.failedReason);
|
||||
}
|
||||
expect(jobStatus?.state).toBe('completed');
|
||||
const flyerId = jobStatus?.returnValue?.flyerId;
|
||||
const flyerId = jobStatus?.data?.flyerId;
|
||||
expect(flyerId).toBeTypeOf('number');
|
||||
createdFlyerIds.push(flyerId);
|
||||
|
||||
@@ -238,7 +259,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
expect(exifResult.tags).toEqual({});
|
||||
expect(exifResult.tags.Software).toBeUndefined();
|
||||
},
|
||||
120000,
|
||||
240000,
|
||||
);
|
||||
|
||||
it(
|
||||
@@ -305,7 +326,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
console.error('[DEBUG] PNG metadata test job failed:', jobStatus.failedReason);
|
||||
}
|
||||
expect(jobStatus?.state).toBe('completed');
|
||||
const flyerId = jobStatus?.returnValue?.flyerId;
|
||||
const flyerId = jobStatus?.data?.flyerId;
|
||||
expect(flyerId).toBeTypeOf('number');
|
||||
createdFlyerIds.push(flyerId);
|
||||
|
||||
@@ -322,6 +343,6 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// The `exif` property should be undefined after the fix.
|
||||
expect(savedImageMetadata.exif).toBeUndefined();
|
||||
},
|
||||
120000,
|
||||
240000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,8 +25,8 @@ 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()}`],
|
||||
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 1, $2) RETURNING flyer_id`,
|
||||
[storeId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
||||
);
|
||||
createdFlyerId = flyerRes.rows[0].flyer_id;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/tests/integration/gamification.integration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import path from 'path';
|
||||
@@ -9,7 +9,13 @@ import { generateFileChecksum } from '../../utils/checksum';
|
||||
import * as db from '../../services/db/index.db';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import type { UserProfile, UserAchievement, LeaderboardUser, Achievement } from '../../types';
|
||||
import type {
|
||||
UserProfile,
|
||||
UserAchievement,
|
||||
LeaderboardUser,
|
||||
Achievement,
|
||||
ExtractedFlyerItem,
|
||||
} from '../../types';
|
||||
import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
|
||||
/**
|
||||
@@ -18,6 +24,9 @@ import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
// Import the mocked service to control its behavior in tests.
|
||||
import { aiService } from '../../services/aiService.server';
|
||||
|
||||
describe('Gamification Flow Integration Test', () => {
|
||||
let testUser: UserProfile;
|
||||
let authToken: string;
|
||||
@@ -31,6 +40,28 @@ describe('Gamification Flow Integration Test', () => {
|
||||
fullName: 'Gamification Tester',
|
||||
request,
|
||||
}));
|
||||
|
||||
// Mock the AI service's method to prevent actual API calls during integration tests.
|
||||
// This is crucial for making the integration test reliable. We don't want to
|
||||
// depend on the external Gemini API, which has quotas and can be slow.
|
||||
// By mocking this, we test our application's internal flow:
|
||||
// API -> Queue -> Worker -> DB -> Gamification Logic
|
||||
const mockExtractedItems: ExtractedFlyerItem[] = [
|
||||
{
|
||||
item: 'Integration Test Milk',
|
||||
price_display: '$4.99',
|
||||
price_in_cents: 499,
|
||||
quantity: '2L',
|
||||
category_name: 'Dairy',
|
||||
},
|
||||
];
|
||||
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockResolvedValue({
|
||||
store_name: 'Gamification Test Store',
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
store_address: null,
|
||||
items: mockExtractedItems,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -81,12 +112,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
|
||||
|
||||
@@ -35,22 +35,22 @@ describe('Price History API Integration Test (/api/price-history)', () => {
|
||||
// 3. Create two flyers with different dates
|
||||
const flyerRes1 = await pool.query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from)
|
||||
VALUES ($1, 'price-test-1.jpg', 'http://test.com/price-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
|
||||
[storeId, `checksum-price-1-${Date.now()}`],
|
||||
VALUES ($1, 'price-test-1.jpg', 'https://example.com/flyer-images/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
|
||||
[storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
|
||||
);
|
||||
flyerId1 = flyerRes1.rows[0].flyer_id;
|
||||
|
||||
const flyerRes2 = await pool.query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from)
|
||||
VALUES ($1, 'price-test-2.jpg', 'http://test.com/price-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
|
||||
[storeId, `checksum-price-2-${Date.now()}`],
|
||||
VALUES ($1, 'price-test-2.jpg', 'https://example.com/flyer-images/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
|
||||
[storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
|
||||
);
|
||||
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
|
||||
|
||||
const flyerRes3 = await pool.query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum, valid_from)
|
||||
VALUES ($1, 'price-test-3.jpg', 'http://test.com/price-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
|
||||
[storeId, `checksum-price-3-${Date.now()}`],
|
||||
VALUES ($1, 'price-test-3.jpg', 'https://example.com/flyer-images/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
|
||||
[storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
|
||||
);
|
||||
flyerId3 = flyerRes3.rows[0].flyer_id;
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ describe('Public API Routes Integration Tests', () => {
|
||||
testStoreId = storeRes.rows[0].store_id;
|
||||
const flyerRes = await pool.query(
|
||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
|
||||
VALUES ($1, 'public-routes-test.jpg', 'http://test.com/public-routes.jpg', 1, $2) RETURNING *`,
|
||||
[testStoreId, `checksum-public-routes-${Date.now()}`],
|
||||
VALUES ($1, 'public-routes-test.jpg', 'https://example.com/flyer-images/public-routes-test.jpg', 1, $2) RETURNING *`,
|
||||
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
|
||||
);
|
||||
testFlyer = flyerRes.rows[0];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/tests/integration/recipe.integration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import { createAndLoginUser } from '../utils/testHelpers';
|
||||
@@ -7,6 +7,8 @@ import { cleanupDb } from '../utils/cleanup';
|
||||
import type { UserProfile, Recipe, RecipeComment } from '../../types';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
|
||||
import { aiService } from '../../services/aiService.server';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
@@ -31,6 +33,9 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
authToken = token;
|
||||
createdUserIds.push(user.user.user_id);
|
||||
|
||||
// Mock the AI service method using spyOn to preserve other exports like DuplicateFlyerError
|
||||
vi.spyOn(aiService, 'generateRecipeSuggestion').mockResolvedValue('Default Mock Suggestion');
|
||||
|
||||
// Create a recipe owned by the test user
|
||||
const recipeRes = await getPool().query(
|
||||
`INSERT INTO public.recipes (name, instructions, user_id, status, description)
|
||||
@@ -124,4 +129,24 @@ describe('Recipe API Routes Integration Tests', () => {
|
||||
it.todo('should prevent a user from deleting another user\'s recipe');
|
||||
it.todo('should allow an authenticated user to post a comment on a recipe');
|
||||
it.todo('should allow an authenticated user to fork a recipe');
|
||||
|
||||
describe('POST /api/recipes/suggest', () => {
|
||||
it('should return a recipe suggestion based on ingredients', async () => {
|
||||
const ingredients = ['chicken', 'rice', 'broccoli'];
|
||||
const mockSuggestion = 'Chicken and Broccoli Stir-fry with Rice';
|
||||
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
|
||||
|
||||
const response = await request
|
||||
.post('/api/recipes/suggest')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ ingredients });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ suggestion: mockSuggestion });
|
||||
expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith(
|
||||
ingredients,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user