262 lines
8 KiB
TypeScript
262 lines
8 KiB
TypeScript
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);
|
||
});
|
||
});
|