import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ShoppingGapService } from '../../../src/modules/meal-plans/shopping-gap.service.js'; import type { MealPlanRepository } from '../../../src/modules/meal-plans/meal-plans.repository.js'; import type { RecipesRepository } from '../../../src/modules/recipes/recipes.repository.js'; import type { PantryRepository } from '../../../src/modules/pantry/pantry.repository.js'; import type { ProductsRepository } from '../../../src/modules/products/products.repository.js'; import { NotFoundError } from '../../../src/common/errors.js'; describe(ShoppingGapService.name, () => { let service: ShoppingGapService; let mockMealRepo: { [K in keyof MealPlanRepository]: ReturnType }; let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType }; let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType }; let mockProductsRepo: { [K in keyof ProductsRepository]: ReturnType }; beforeEach(() => { mockMealRepo = { findById: vi.fn() } as never; mockRecipesRepo = { findById: vi.fn() } as never; mockPantryRepo = { findActiveByHousehold: vi.fn() } as never; mockProductsRepo = { findByIds: vi.fn() } as never; service = new ShoppingGapService({ mealPlanRepository: mockMealRepo as unknown as MealPlanRepository, recipesRepository: mockRecipesRepo as unknown as RecipesRepository, pantryRepository: mockPantryRepo as unknown as PantryRepository, productsRepository: mockProductsRepo as unknown as ProductsRepository, }); }); describe('calculateGap', () => { it('throws NotFoundError if plan is missing', async () => { mockMealRepo.findById.mockResolvedValue(null); await expect(service.calculateGap('hh1', 'p1')).rejects.toThrow(NotFoundError); }); it('correctly scales recipe ingredients and contrasts against pantry', async () => { // 1. Setup Meal Plan with 1 meal // Recipe A planned for 4 servings. mockMealRepo.findById.mockResolvedValue({ _id: 'plan1', days: [ { meals: [ { recipeId: 'recipe1', servings: 4 } ] } ] }); // 2. Recipe 1: serves 2, needs 100g of ProdA (total needed = 200g for 4 servings) mockRecipesRepo.findById.mockResolvedValue({ _id: 'recipe1', servings: 2, ingredients: [ { productId: 'prodA', quantity: 100, unit: 'g', isOptional: false } ] }); // 3. Products Info mockProductsRepo.findByIds.mockResolvedValue([ { _id: 'prodA', name: 'Flour', category: 'baking' } ]); // 4. Pantry only has 50g of ProdA. Missing amount should be 150g! mockPantryRepo.findActiveByHousehold.mockResolvedValue([ { productId: 'prodA', quantity: 50 } ]); const result = await service.calculateGap('hh1', 'plan1'); expect(result.mealPlanId).toBe('plan1'); expect(result.missingItems.length).toBe(1); const gap = result.missingItems[0]!; expect(gap.productId).toBe('prodA'); expect(gap.productName).toBe('Flour'); expect(gap.requiredQuantity).toBe(200); // 100g * (4 planned / 2 base) expect(gap.pantryQuantity).toBe(50); expect(gap.missingQuantity).toBe(150); expect(gap.unit).toBe('g'); }); it('does not include products that are fully stocked', async () => { mockMealRepo.findById.mockResolvedValue({ _id: 'plan2', days: [ { meals: [{ recipeId: 'recipe1', servings: 2 }] } ] }); mockRecipesRepo.findById.mockResolvedValue({ _id: 'recipe1', servings: 2, ingredients: [ { productId: 'prodB', quantity: 50, unit: 'g', isOptional: false } ] }); mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodB', name: 'Salt' }]); // Pantry has 100g (more than enough) mockPantryRepo.findActiveByHousehold.mockResolvedValue([{ productId: 'prodB', quantity: 100 }]); const result = await service.calculateGap('hh1', 'plan2'); expect(result.missingItems.length).toBe(0); }); it('aggregates duplicate ingredients and sorts by product name', async () => { mockMealRepo.findById.mockResolvedValue({ _id: 'plan-multi', days: [ { meals: [ { recipeId: 'recipeA', servings: 1 }, { recipeId: 'recipeB', servings: 1 }, ] } ] }); mockRecipesRepo.findById.mockImplementation(async (id) => { if (id === 'recipeA') { return { _id: 'recipeA', servings: 1, ingredients: [{ productId: 'prod1', quantity: 10, isOptional: false }] }; } return { _id: 'recipeB', servings: 1, ingredients: [ { productId: 'prod1', quantity: 20, isOptional: false }, { productId: 'prod2', quantity: 5, isOptional: false }, ] }; }); mockProductsRepo.findByIds.mockResolvedValue([ { _id: 'prod1', name: 'Banana' }, { _id: 'prod2', name: 'Apple' }, ]); mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); const result = await service.calculateGap('hh1', 'plan-multi'); expect(result.missingItems).toHaveLength(2); expect(result.missingItems[0].productName).toBe('Apple'); expect(result.missingItems[1].productName).toBe('Banana'); expect(result.missingItems[1].requiredQuantity).toBe(30); }); it('covers fallback paths for missing list, recipe properties and pantry quantities', async () => { mockMealRepo.findById.mockResolvedValue({ _id: 'plan-empty', }); mockProductsRepo.findByIds.mockResolvedValue([]); mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); let res = await service.calculateGap('hh1', 'plan-empty'); expect(res.missingItems).toHaveLength(0); mockMealRepo.findById.mockResolvedValue({ _id: 'plan-missing', days: [ { meals: [{ recipeId: 'recipeC', servings: 1 }] } ] }); mockRecipesRepo.findById.mockResolvedValue({ _id: 'recipeC', servings: 1, ingredients: [ { productId: 'prod3', quantity: 10, isOptional: false } ] }); mockProductsRepo.findByIds.mockResolvedValue([ { _id: 'prod3' } ]); mockPantryRepo.findActiveByHousehold.mockResolvedValue([ { productId: 'prod3' } ]); res = await service.calculateGap('hh1', 'plan-missing'); expect(res.missingItems).toHaveLength(1); const itm = res.missingItems[0]!; expect(itm.unit).toBe('g'); expect(itm.productName).toBe('Unknown Ingredient'); expect(itm.category).toBe('other'); expect(itm.pantryQuantity).toBe(0); }); it('skips optional ingredients, handles missing recipes and defaults servings to 1', async () => { mockMealRepo.findById.mockResolvedValue({ _id: 'plan-edge', days: [ { meals: [ { recipeId: 'recipeExist', servings: 2 }, { recipeId: 'recipeNotExist', servings: 1 }, ] } ] }); mockRecipesRepo.findById.mockImplementation(async (id) => { if (id === 'recipeExist') { return { _id: 'recipeExist', servings: 0, ingredients: [ { productId: 'prodIng', quantity: 5, isOptional: false }, { productId: 'prodOptional', quantity: 10, isOptional: true }, ] }; } return null; }); mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodIng', name: 'Ingredient' }]); mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); const result = await service.calculateGap('hh1', 'plan-edge'); expect(result.missingItems).toHaveLength(1); expect(result.missingItems[0].productId).toBe('prodIng'); expect(result.missingItems[0].requiredQuantity).toBe(10); }); }); });