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) => {props.children},
}));
import { RecipeEditor } from '../../../../src/app/(dashboard)/recipes/RecipeEditor';
beforeEach(() => vi.clearAllMocks());
describe('RecipeEditor', () => {
it('renders create form by default', () => {
render();
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();
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();
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();
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();
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();
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();
fireEvent.click(screen.getByText('+ Add ingredient'));
const nameInputs = screen.getAllByPlaceholderText('Product name');
expect(nameInputs).toHaveLength(2);
});
it('can add and remove steps', () => {
render();
fireEvent.click(screen.getByText('+ Add step'));
const stepInputs = screen.getAllByPlaceholderText('Instruction...');
expect(stepInputs).toHaveLength(2);
});
it('cancel button calls router.back', () => {
render();
fireEvent.click(screen.getByText('Cancel'));
expect(mockBack).toHaveBeenCalled();
});
it('handles updating ingredient optional properties and removing ingredients', () => {
render();
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();
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);
});
});