MeshiTrack/packages/api/tests/modules/recipes/recipes.service.test.ts

345 lines
11 KiB
TypeScript
Raw Normal View History

2026-05-14 14:47:23 +09:00
import { describe, it, expect, vi, beforeEach } from 'vitest';
2026-05-19 11:06:03 +09:00
import { RecipesService } from '../../../src/modules/recipes/recipes.service.js';
import { NotFoundError, BadRequestError } from '../../../src/common/errors.js';
2026-05-14 14:47:23 +09:00
const makeProduct = (id: string, servingUnit = 'g', servingSize = 100) => ({
_id: { toString: () => id },
householdId: 'hh1',
name: 'Test Product',
servingSize,
servingUnit,
densityGPerMl: undefined as number | undefined,
nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 },
});
const makeRecipe = (id = 'recipe-1') => ({
_id: { toString: () => id },
householdId: 'hh1',
name: 'Test Recipe',
servings: 2,
ingredients: [
{
productId: 'p1',
productName: 'Chicken',
quantity: 200,
unit: 'g',
isOptional: false,
nutritionContribution: { calories: 400, protein: 40, carbs: 0, fat: 16 },
},
],
steps: [],
tags: [],
isFavorite: false,
totalNutrition: { calories: 400, protein: 40, carbs: 0, fat: 16 },
perServingNutrition: { calories: 200, protein: 20, carbs: 0, fat: 8 },
warnings: [],
createdBy: 'user-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
describe(RecipesService.name, () => {
const mockRecipesRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findByProductId: vi.fn(),
findAllByProductId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockProductsRepo = {
findById: vi.fn(),
findByIds: vi.fn(),
};
const mockLlmProvider = {
extractNutrition: vi.fn(),
parseRecipe: vi.fn(),
parseRecipeFromUrl: vi.fn(),
parseReceipt: vi.fn(),
suggestMealPlan: vi.fn(),
parseNaturalLanguage: vi.fn(),
};
let service: RecipesService;
beforeEach(() => {
vi.clearAllMocks();
service = new RecipesService({
recipesRepository: mockRecipesRepo as never,
productsRepository: mockProductsRepo as never,
llmProvider: mockLlmProvider as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockRecipesRepo.findByHousehold.mockResolvedValue(expected);
const result = await service.list('hh1', { limit: 20 });
expect(mockRecipesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getById', () => {
it('returns recipe when found', async () => {
const recipe = makeRecipe();
mockRecipesRepo.findById.mockResolvedValue(recipe);
const result = await service.getById('recipe-1', 'hh1');
expect(result).toEqual(recipe);
});
it('throws NotFoundError when not found', async () => {
mockRecipesRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('normalizes metric ingredients and calculates nutrition', async () => {
const product = makeProduct('p1');
mockProductsRepo.findByIds.mockResolvedValue([product]);
mockRecipesRepo.create.mockResolvedValue(makeRecipe());
await service.create(
{
name: 'Test',
servings: 2,
ingredients: [
{
productId: 'p1',
productName: 'Chicken',
quantity: 200,
unit: 'g',
isOptional: false,
},
],
steps: [],
tags: [],
isFavorite: false,
},
'hh1',
'user-1',
);
const [_, computed] = mockRecipesRepo.create.mock.calls[0]!;
expect(computed.totalNutrition.calories).toBe(400); // 200g = 2× of 100g serving (200 kcal each)
expect(computed.perServingNutrition.calories).toBe(200);
});
it('throws BadRequestError for missing density on cup → g conversion', async () => {
const product = makeProduct('p1', 'g'); // g product, no density
mockProductsRepo.findByIds.mockResolvedValue([product]);
await expect(
service.create(
{
name: 'Test',
servings: 1,
ingredients: [
{
productId: 'p1',
productName: 'Sugar',
quantity: 1,
unit: 'cup',
isOptional: false,
},
],
steps: [],
tags: [],
isFavorite: false,
},
'hh1',
'user-1',
),
).rejects.toThrow(BadRequestError);
});
it('throws NotFoundError for unknown product', async () => {
mockProductsRepo.findByIds.mockResolvedValue([]);
await expect(
service.create(
{
name: 'Test',
servings: 1,
ingredients: [
{
productId: 'unknown',
productName: 'X',
quantity: 100,
unit: 'g',
isOptional: false,
},
],
steps: [],
tags: [],
isFavorite: false,
},
'hh1',
'user-1',
),
).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('soft-deletes recipe', async () => {
const recipe = makeRecipe();
mockRecipesRepo.findById.mockResolvedValue(recipe);
mockRecipesRepo.softDelete.mockResolvedValue(recipe);
const result = await service.delete('recipe-1', 'hh1');
expect(mockRecipesRepo.softDelete).toHaveBeenCalledWith('recipe-1', 'hh1');
expect(result).toEqual(recipe);
});
it('throws NotFoundError when not found', async () => {
mockRecipesRepo.findById.mockResolvedValue(null);
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
mockRecipesRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('recipe-1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('scale', () => {
it('returns scaled ingredient quantities and recalculated nutrition', async () => {
const recipe = makeRecipe();
mockRecipesRepo.findById.mockResolvedValue(recipe);
mockProductsRepo.findByIds.mockResolvedValue([makeProduct('p1')]);
const result = await service.scale('recipe-1', 'hh1', { targetServings: 4 });
expect(result.servings).toBe(4);
// 200g × (4/2) = 400g
expect(result.ingredients[0]!.quantity).toBe(400);
expect(result.totalNutrition.calories).toBe(800);
});
});
describe('importFromText', () => {
it('returns available:false when LLM returns null', async () => {
mockLlmProvider.parseRecipe.mockResolvedValue(null);
const result = await service.importFromText('some text', 'hh1');
expect(result).toEqual({ available: false });
});
it('returns draft when LLM returns a recipe', async () => {
const draft = { name: 'Pasta', servings: 4, ingredients: [], steps: [] };
mockLlmProvider.parseRecipe.mockResolvedValue(draft);
const result = await service.importFromText('pasta recipe', 'hh1');
expect(result).toEqual({ available: true, draft });
});
});
describe('importFromUrl', () => {
it('returns available:false when LLM returns null', async () => {
mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(null);
const result = await service.importFromUrl('https://example.com/recipe', 'hh1');
expect(result).toEqual({ available: false });
});
it('returns draft when LLM returns a recipe', async () => {
const draft = { name: 'Soup', servings: 2, ingredients: [], steps: [] };
mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(draft);
const result = await service.importFromUrl('https://example.com', 'hh1');
expect(result).toEqual({ available: true, draft });
});
});
describe('update', () => {
it('updates metadata without recalculating if no ingredients/servings changed', async () => {
const recipe = makeRecipe();
mockRecipesRepo.findById.mockResolvedValue(recipe);
mockRecipesRepo.update.mockResolvedValue({ ...recipe, name: 'Renamed' });
const result = await service.update('recipe-1', 'hh1', { name: 'Renamed' });
expect(result.name).toBe('Renamed');
expect(mockProductsRepo.findByIds).not.toHaveBeenCalled();
});
it('recalculates nutrition when ingredients change', async () => {
const recipe = makeRecipe();
const product = makeProduct('p1');
mockRecipesRepo.findById.mockResolvedValue(recipe);
mockProductsRepo.findByIds.mockResolvedValue([product]);
mockRecipesRepo.update.mockResolvedValue(recipe);
await service.update('recipe-1', 'hh1', {
ingredients: [
{
productId: 'p1',
productName: 'Chicken',
quantity: 300,
unit: 'g',
isOptional: false,
},
],
});
expect(mockProductsRepo.findByIds).toHaveBeenCalled();
expect(mockRecipesRepo.update).toHaveBeenCalled();
});
it('recalculates nutrition when only servings change', async () => {
const recipe = makeRecipe();
const product = makeProduct('p1');
mockRecipesRepo.findById.mockResolvedValue(recipe);
mockProductsRepo.findByIds.mockResolvedValue([product]);
mockRecipesRepo.update.mockResolvedValue(recipe);
await service.update('recipe-1', 'hh1', { servings: 4 });
expect(mockProductsRepo.findByIds).toHaveBeenCalled();
});
it('throws NotFoundError when update returns null', async () => {
mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
mockRecipesRepo.update.mockResolvedValue(null);
await expect(service.update('recipe-1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
});
});
describe('findByProduct', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockRecipesRepo.findByProductId.mockResolvedValue(expected);
const result = await service.findByProduct('hh1', 'p1', { limit: 20 });
expect(mockRecipesRepo.findByProductId).toHaveBeenCalledWith('hh1', 'p1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('recalculateForProduct', () => {
it('recalculates all recipes containing the product', async () => {
const recipe = makeRecipe();
const product = makeProduct('p1');
mockRecipesRepo.findAllByProductId.mockResolvedValue([recipe]);
mockProductsRepo.findByIds.mockResolvedValue([product]);
mockRecipesRepo.update.mockResolvedValue(recipe);
await service.recalculateForProduct('hh1', 'p1');
expect(mockRecipesRepo.findAllByProductId).toHaveBeenCalledWith('hh1', 'p1');
expect(mockRecipesRepo.update).toHaveBeenCalledTimes(1);
});
it('does nothing when no recipes contain the product', async () => {
mockRecipesRepo.findAllByProductId.mockResolvedValue([]);
await service.recalculateForProduct('hh1', 'p1');
expect(mockRecipesRepo.update).not.toHaveBeenCalled();
});
});
});