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); }); });