MeshiTrack/packages/web/tests/app/(dashboard)/recipes/RecipeEditor.test.tsx

262 lines
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockCreateRecipe, mockUpdateRecipe } = vi.hoisted(() => ({
mockCreateRecipe: vi.fn(),
mockUpdateRecipe: vi.fn(),
}));
const { mockPush, mockBack } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockBack: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
createRecipe: mockCreateRecipe,
updateRecipe: mockUpdateRecipe,
listRecipes: vi.fn(),
deleteRecipe: vi.fn(),
getRecipe: vi.fn(),
scaleRecipe: vi.fn(),
importRecipeFromText: vi.fn(),
importRecipeFromUrl: vi.fn(),
listRecipesByProduct: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush, back: mockBack }),
useParams: () => ({ id: 'r1' }),
}));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
}));
import { RecipeEditor } from '../../../../src/app/(dashboard)/recipes/RecipeEditor';
beforeEach(() => vi.clearAllMocks());
describe('RecipeEditor', () => {
it('renders create form by default', () => {
render(<RecipeEditor householdId="hh1" />);
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
expect(screen.getByText('Create recipe')).toBeInTheDocument();
expect(screen.getByText('Ingredients')).toBeInTheDocument();
expect(screen.getByText('Instructions')).toBeInTheDocument();
});
it('renders edit form when existing recipe provided', () => {
const existing = {
_id: 'r1',
name: 'Pasta',
description: 'Good pasta',
servings: 2,
prepTime: 10,
cookTime: 20,
cuisine: 'Italian',
tags: ['pasta', 'quick'],
isFavorite: true,
ingredients: [
{
productId: 'p1',
productName: 'Spaghetti',
quantity: 200,
unit: 'g',
originalQuantity: 200,
originalUnit: 'g',
isOptional: false,
},
],
steps: [{ order: 1, instruction: 'Boil water', duration: 5, tip: 'Use salted water' }],
perServingNutrition: { calories: 300, protein: 10, carbs: 40, fat: 8 },
totalNutrition: { calories: 600, protein: 20, carbs: 80, fat: 16 },
warnings: [],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
expect(screen.getByDisplayValue('Good pasta')).toBeInTheDocument();
expect(screen.getByText('Save changes')).toBeInTheDocument();
});
it('submits create form', async () => {
mockCreateRecipe.mockResolvedValue({ _id: 'new1' });
render(<RecipeEditor householdId="hh1" />);
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
target: { value: 'New Recipe' },
});
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Flour' },
});
fireEvent.click(screen.getByText('Create recipe'));
await waitFor(() => {
expect(mockCreateRecipe).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'New Recipe' }),
);
expect(mockPush).toHaveBeenCalledWith('/recipes/new1');
});
});
it('submits update form', async () => {
mockUpdateRecipe.mockResolvedValue({ _id: 'r1' });
const existing = {
_id: 'r1',
name: 'Old Name',
description: '',
servings: 4,
tags: [],
isFavorite: false,
ingredients: [
{
productId: 'p1',
productName: 'Test',
quantity: 100,
unit: 'g',
isOptional: false,
},
],
steps: [{ order: 1, instruction: 'Do thing' }],
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 3 },
totalNutrition: { calories: 400, protein: 20, carbs: 40, fat: 12 },
warnings: [],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
fireEvent.change(screen.getByDisplayValue('Old Name'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Save changes'));
await waitFor(() => {
expect(mockUpdateRecipe).toHaveBeenCalledWith(
'hh1',
'r1',
expect.objectContaining({ name: 'New Name' }),
);
expect(mockPush).toHaveBeenCalledWith('/recipes/r1');
});
});
it('shows error when create fails', async () => {
mockCreateRecipe.mockRejectedValue(new Error('Server error'));
render(<RecipeEditor householdId="hh1" />);
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Item' },
});
fireEvent.click(screen.getByText('Create recipe'));
await waitFor(() => {
expect(screen.getByText('Server error')).toBeInTheDocument();
});
});
it('shows fallback error for non-Error failure', async () => {
mockCreateRecipe.mockRejectedValue('unexpected');
render(<RecipeEditor householdId="hh1" />);
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('Product name'), {
target: { value: 'Item' },
});
fireEvent.click(screen.getByText('Create recipe'));
await waitFor(() => {
expect(screen.getByText('Failed to save recipe')).toBeInTheDocument();
});
});
it('can add and remove ingredients', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add ingredient'));
const nameInputs = screen.getAllByPlaceholderText('Product name');
expect(nameInputs).toHaveLength(2);
});
it('can add and remove steps', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add step'));
const stepInputs = screen.getAllByPlaceholderText('Instruction...');
expect(stepInputs).toHaveLength(2);
});
it('cancel button calls router.back', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('Cancel'));
expect(mockBack).toHaveBeenCalled();
});
it('handles updating ingredient optional properties and removing ingredients', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add ingredient'));
const prepInputs = screen.getAllByPlaceholderText(/Preparation/i);
fireEvent.change(prepInputs[0], { target: { value: 'Diced' } });
const removeBtns = screen.getAllByRole('button', { name: '×' });
fireEvent.click(removeBtns[0]);
const remainingPrepInputs = screen.getAllByPlaceholderText(/Preparation/i);
expect(remainingPrepInputs).toHaveLength(1);
});
it('handles step metadata and removing steps', () => {
render(<RecipeEditor householdId="hh1" />);
fireEvent.click(screen.getByText('+ Add step'));
const durationInputs = screen.getAllByPlaceholderText(/Duration/i);
const tipInputs = screen.getAllByPlaceholderText(/Tip/i);
fireEvent.change(durationInputs[0], { target: { value: '15' } });
fireEvent.change(tipInputs[0], { target: { value: "Don't burn it" } });
const removeBtns = screen.getAllByRole('button', { name: '×' });
fireEvent.click(removeBtns[removeBtns.length - 1]);
const remainingDurationInputs = screen.getAllByPlaceholderText(/Duration/i);
expect(remainingDurationInputs).toHaveLength(1);
});
});