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