Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
344
packages/api/tests/modules/recipes/recipes.service.test.ts
Normal file
344
packages/api/tests/modules/recipes/recipes.service.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue