MeshiTrack/packages/api/tests/modules/meal-plans/shopping-gap.service.test.ts

235 lines
8.1 KiB
TypeScript
Raw Normal View History

2026-05-14 18:38:50 +09:00
import { describe, it, expect, vi, beforeEach } from 'vitest';
2026-05-19 11:06:03 +09:00
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';
2026-05-14 18:38:50 +09:00
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);
});
2026-05-19 10:13:40 +09:00
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);
});
2026-05-14 18:38:50 +09:00
});
});