diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 00000000..83de1f54 --- /dev/null +++ b/src/components/Dashboard.tsx @@ -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 ( +
+

Dashboard

+ +
+ {/* Main Content Area */} +
+ {/* Recipe Suggester Section */} + + + {/* Other Dashboard Widgets */} +
+

Your Flyers

+ +
+
+ + {/* Sidebar Area */} +
+ +
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/src/components/RecipeSuggester.tsx b/src/components/RecipeSuggester.tsx new file mode 100644 index 00000000..2e31ca8e --- /dev/null +++ b/src/components/RecipeSuggester.tsx @@ -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(''); + const [suggestion, setSuggestion] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = useCallback(async (event: React.FormEvent) => { + 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 ( +
+

Get a Recipe Suggestion

+

Enter some ingredients you have, separated by commas.

+
+
+ + 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" + /> +
+ +
+ + {error && ( +
{error}
+ )} + + {suggestion && ( +
+
+
Recipe Suggestion
+

{suggestion}

+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/routes/passport.routes.test.ts b/src/routes/passport.routes.test.ts index 0828f9ee..b33dc774 100644 --- a/src/routes/passport.routes.test.ts +++ b/src/routes/passport.routes.test.ts @@ -482,8 +482,8 @@ describe('Passport Configuration', () => { const mockReq: Partial = { // 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 diff --git a/src/routes/recipe.routes.ts b/src/routes/recipe.routes.ts index 62026c64..c4dfa1d1 100644 --- a/src/routes/recipe.routes.ts +++ b/src/routes/recipe.routes.ts @@ -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; + 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; diff --git a/src/services/aiService.server.test.ts b/src/services/aiService.server.test.ts index 537e43eb..cab8fcc2 100644 --- a/src/services/aiService.server.test.ts +++ b/src/services/aiService.server.test.ts @@ -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'; @@ -783,7 +784,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 +799,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 +822,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 +851,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 +888,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( diff --git a/src/services/aiService.server.ts b/src/services/aiService.server.ts index c30cb2a6..3a0c94df 100644 --- a/src/services/aiService.server.ts +++ b/src/services/aiService.server.ts @@ -62,6 +62,7 @@ interface IAiClient { generateContent(request: { contents: Content[]; tools?: Tool[]; + useLiteModels?: boolean; }): Promise; } @@ -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 { 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 { + 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. diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 9dfffbf6..0853704f 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -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', () => { diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 5d2db8bd..d93f3049 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -636,6 +636,20 @@ export const addRecipeComment = ( ): Promise => 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 => { + // 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. diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 8bae339a..897fcda4 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -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. diff --git a/src/services/db/user.db.test.ts b/src/services/db/user.db.test.ts index 631ebf2a..a8b3e37b 100644 --- a/src/services/db/user.db.test.ts +++ b/src/services/db/user.db.test.ts @@ -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] }); diff --git a/src/services/flyerAiProcessor.server.test.ts b/src/services/flyerAiProcessor.server.test.ts index e80eae10..7deaa24d 100644 --- a/src/services/flyerAiProcessor.server.test.ts +++ b/src/services/flyerAiProcessor.server.test.ts @@ -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' }]; diff --git a/src/tests/integration/flyer-processing.integration.test.ts b/src/tests/integration/flyer-processing.integration.test.ts index a6780fde..c9247498 100644 --- a/src/tests/integration/flyer-processing.integration.test.ts +++ b/src/tests/integration/flyer-processing.integration.test.ts @@ -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,22 @@ import piexif from 'piexifjs'; import exifParser from 'exif-parser'; import sharp from 'sharp'; +// Mock the AI service to prevent actual API calls during integration tests. +vi.mock('../../services/aiService.server', () => ({ + aiService: { + extractCoreDataFromFlyerImage: vi.fn(), + }, +})); + /** * @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 +39,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.mocked(aiService.extractCoreDataFromFlyerImage).mockResolvedValue({ + store_name: 'Mock Store', + valid_from: null, + valid_to: null, + store_address: null, + items: mockItems, + }); }); afterAll(async () => { @@ -219,7 +246,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); @@ -305,7 +332,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); diff --git a/src/tests/integration/gamification.integration.test.ts b/src/tests/integration/gamification.integration.test.ts index d4bf322a..65eb7d31 100644 --- a/src/tests/integration/gamification.integration.test.ts +++ b/src/tests/integration/gamification.integration.test.ts @@ -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,15 +9,31 @@ 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'; +// Mock the AI service to prevent actual API calls during integration tests. +vi.mock('../../services/aiService.server', () => ({ + aiService: { + extractCoreDataFromFlyerImage: vi.fn(), + }, +})); + /** * @vitest-environment node */ 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; @@ -44,6 +60,28 @@ describe('Gamification Flow Integration Test', () => { it( 'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer', async () => { + // --- Arrange: Mock AI Service Response --- + // 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.mocked(aiService.extractCoreDataFromFlyerImage).mockResolvedValue({ + store_name: 'Gamification Test Store', + valid_from: null, + valid_to: null, + store_address: null, + items: mockExtractedItems, + }); + // --- Arrange: Prepare a unique flyer file for upload --- const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); diff --git a/src/tests/integration/price.integration.test.ts b/src/tests/integration/price.integration.test.ts index 8c6fa629..a9136eaa 100644 --- a/src/tests/integration/price.integration.test.ts +++ b/src/tests/integration/price.integration.test.ts @@ -49,7 +49,7 @@ describe('Price History API Integration Test (/api/price-history)', () => { 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', '/flyer-images/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`, + 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; diff --git a/src/tests/integration/recipe.integration.test.ts b/src/tests/integration/recipe.integration.test.ts index a35c8029..9a945dba 100644 --- a/src/tests/integration/recipe.integration.test.ts +++ b/src/tests/integration/recipe.integration.test.ts @@ -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,14 @@ import { cleanupDb } from '../utils/cleanup'; import type { UserProfile, Recipe, RecipeComment } from '../../types'; import { getPool } from '../../services/db/connection.db'; +// Mock the AI service +vi.mock('../../services/aiService.server', () => ({ + aiService: { + generateRecipeSuggestion: vi.fn(), + }, +})); +import { aiService } from '../../services/aiService.server'; + /** * @vitest-environment node */ @@ -124,4 +132,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(), + ); + }); + }); }); \ No newline at end of file