This commit is contained in:
Aerilyn Weber 2026-05-14 18:38:50 +09:00
parent 029940b079
commit e396f5088c
36 changed files with 4199 additions and 22 deletions

View file

@ -0,0 +1,110 @@
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);
});
});
});