111 lines
4 KiB
TypeScript
111 lines
4 KiB
TypeScript
|
|
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<typeof vi.fn> };
|
||
|
|
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
|
||
|
|
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
|
||
|
|
let mockProductsRepo: { [K in keyof ProductsRepository]: ReturnType<typeof vi.fn> };
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|