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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|