import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ShoppingGapService } from './shopping-gap.service.js'; import type { MealPlanRepository } from './meal-plans.repository.js'; import type { RecipesRepository } from '../recipes/recipes.repository.js'; import type { PantryRepository } from '../pantry/pantry.repository.js'; import type { ProductsRepository } from '../products/products.repository.js'; import { NotFoundError } from '../../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); }); }); });