import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SuggestionEngineService } from './suggestion-engine.service.js'; import type { RecipesRepository } from '../recipes/recipes.repository.js'; import type { PantryRepository } from '../pantry/pantry.repository.js'; import type { MealPlanRepository } from './meal-plans.repository.js'; import type { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js'; describe(SuggestionEngineService.name, () => { let service: SuggestionEngineService; let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType }; let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType }; let mockMealPlanRepo: { [K in keyof MealPlanRepository]: ReturnType }; let mockNutritionRepo: { [K in keyof NutritionTargetRepository]: ReturnType }; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-20T00:00:00Z')); mockRecipesRepo = { findByHousehold: vi.fn(), } as never; mockPantryRepo = { findActiveByHousehold: vi.fn(), } as never; mockMealPlanRepo = { findByHousehold: vi.fn(), } as never; mockNutritionRepo = { findByUser: vi.fn(), } as never; service = new SuggestionEngineService({ recipesRepository: mockRecipesRepo as unknown as RecipesRepository, pantryRepository: mockPantryRepo as unknown as PantryRepository, mealPlanRepository: mockMealPlanRepo as unknown as MealPlanRepository, nutritionTargetRepository: mockNutritionRepo as unknown as NutritionTargetRepository, }); }); afterEach(() => { vi.useRealTimers(); }); describe('getSuggestions', () => { it('correctly ranks recipes based on inventory coverage and freshness', async () => { // 1. Set up recipes: // - Recipe A: Needs Product 1 (2 units) and Product 2 (1 unit) // - Recipe B: Needs Product 3 (1 unit) const recipeA = { _id: 'recipeA', name: 'Recipe A', ingredients: [ { productId: 'prod1', quantity: 2, isOptional: false }, { productId: 'prod2', quantity: 1, isOptional: false }, ], perServingNutrition: { calories: 400, protein: 30, carbs: 40, fat: 10 }, // balanced }; const recipeB = { _id: 'recipeB', name: 'Recipe B', ingredients: [ { productId: 'prod3', quantity: 1, isOptional: false }, ], perServingNutrition: { calories: 600, protein: 10, carbs: 100, fat: 15 }, // high carb }; mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeA, recipeB], pagination: { hasMore: false }, }); // 2. Set up Pantry inventory: // We have Product 1 in abundance (expiringSoon). // We have Product 2 (fresh). // Product 3 is NOT in pantry. mockPantryRepo.findActiveByHousehold.mockResolvedValue([ { productId: 'prod1', quantity: 10, freshnessEstimate: { daysRemaining: 2, urgency: 'expiringSoon' }, }, { productId: 'prod2', quantity: 5, freshnessEstimate: { daysRemaining: 15, urgency: 'fresh' }, }, ]); // 3. Set up standard nutrition target (Maintenance: 30p/40c/30f split) // Macro split match logic: // Recipe A: 400cals, 30g Protein(120cals=30%), 40g Carbs(160cals=40%), 10g Fat(90cals=22.5%) -> highly aligned! mockNutritionRepo.findByUser.mockResolvedValue({ dailyCalories: 2000, proteinG: 150, // (150 * 4) = 600cals (30%) carbsG: 200, // (200 * 4) = 800cals (40%) fatG: 67, // (67 * 9) = 603cals (30%) }); // 4. Set up recent meal plans (empty history -> 100% Variety for all) mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [], }); // Run suggestion fetch const suggestions = await service.getSuggestions('hh1', 'user1'); // Assertions: expect(suggestions.length).toBe(2); // Recipe A should clearly rank #1 (100% Coverage, using urgent items, highly nutritious match) const top = suggestions[0]!; expect(top.recipeId).toBe('recipeA'); expect(top.scores.coverage).toBe(1); // full coverage // Urgency: (expiringSoon[0.7] + fresh[0.1]) / 2 = 0.4 expect(top.scores.urgency).toBeGreaterThan(0.3); expect(top.scores.variety).toBe(1); // never eaten // Recipe B should have 0 coverage and thus lower totalScore const bottom = suggestions[1]!; expect(bottom.recipeId).toBe('recipeB'); expect(bottom.scores.coverage).toBe(0); expect(bottom.totalScore).toBeLessThan(top.totalScore); }); it('penalizes recipes eaten recently (Variety score)', async () => { const recipeX = { _id: 'recipeX', name: 'Recipe X', ingredients: [], perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 2 }, }; mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeX] }); mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); mockNutritionRepo.findByUser.mockResolvedValue(null); // Fake history: Recipe X was eaten 7 days ago const date7DaysAgo = new Date(); date7DaysAgo.setDate(date7DaysAgo.getDate() - 7); const dateStr = date7DaysAgo.toISOString().split('T')[0]; mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [ { days: [ { date: dateStr, meals: [{ recipeId: 'recipeX' }], }, ], }, ], }); const suggestions = await service.getSuggestions('hh1', 'user1'); // Variety calculation: 7 days ago / 14 days = 0.5 expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1); }); }); });