2026-05-14 14:47:23 +09:00
|
|
|
|
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>,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
|
import { RecipeEditor } from '../../../../src/app/(dashboard)/recipes/RecipeEditor';
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-05-19 10:13:40 +09:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-14 14:47:23 +09:00
|
|
|
|
});
|