import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RecipesService } from '../../../src/modules/recipes/recipes.service.js'; import { NotFoundError, BadRequestError } from '../../../src/common/errors.js'; 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(); }); }); });