import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn(), })); const { mockGetRecipe, mockScaleRecipe } = vi.hoisted(() => ({ mockGetRecipe: vi.fn(), mockScaleRecipe: vi.fn(), })); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/services/recipes', () => ({ getRecipe: mockGetRecipe, scaleRecipe: mockScaleRecipe, listRecipes: vi.fn(), deleteRecipe: vi.fn(), createRecipe: vi.fn(), updateRecipe: vi.fn(), importRecipeFromText: vi.fn(), importRecipeFromUrl: vi.fn(), listRecipesByProduct: vi.fn(), })); vi.mock('next/navigation', () => ({ useParams: () => ({ id: 'r1' }), useRouter: () => ({ push: vi.fn(), back: vi.fn() }), })); vi.mock('next/link', () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any default: (props: any) => {props.children}, })); import RecipeDetailPage from '../../../../../src/app/(dashboard)/recipes/[id]/page'; beforeEach(() => vi.clearAllMocks()); const SAMPLE_RECIPE = { _id: 'r1', householdId: 'hh1', name: 'Spaghetti Bolognese', description: 'Classic Italian pasta', servings: 4, prepTime: 15, cookTime: 30, totalTime: 45, cuisine: 'Italian', tags: ['pasta'], isFavorite: true, ingredients: [ { productId: 'p1', productName: 'Spaghetti', quantity: 400, unit: 'g', isOptional: false, }, { productId: 'p2', productName: 'Parmesan', quantity: 50, unit: 'g', isOptional: true, preparation: 'grated', }, ], steps: [ { order: 1, instruction: 'Boil water', duration: 10 }, { order: 2, instruction: 'Cook pasta', tip: 'Al dente' }, ], perServingNutrition: { calories: 450, protein: 25, carbs: 55, fat: 12, fiber: 3, sugar: 5, sodium: 400, saturatedFat: 4, cholesterol: 50, }, totalNutrition: { calories: 1800, protein: 100, carbs: 220, fat: 48, }, warnings: ['high_calories'], createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }; describe('RecipeDetailPage', () => { it('shows loading skeleton', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: true }); render(); expect(screen.getByText('Recipe')).toBeInTheDocument(); }); it('shows error when fetch fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockRejectedValue(new Error('Not found')); render(); await waitFor(() => { expect(screen.getByText('Not found')).toBeInTheDocument(); }); }); it('shows recipe not found fallback', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); // Resolve with null-like to trigger error path mockGetRecipe.mockRejectedValue(new Error('Recipe not found')); render(); await waitFor(() => { expect(screen.getByText('Recipe not found')).toBeInTheDocument(); }); }); it('renders recipe details', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await waitFor(() => { expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); expect(screen.getByText('Classic Italian pasta')).toBeInTheDocument(); expect(screen.getByText('4 servings')).toBeInTheDocument(); expect(screen.getByText('Starred')).toBeInTheDocument(); }); }); it('shows ingredients with optional indicator', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await waitFor(() => { expect(screen.getByText('Spaghetti')).toBeInTheDocument(); expect(screen.getByText('Parmesan')).toBeInTheDocument(); expect(screen.getByText('(optional)')).toBeInTheDocument(); }); }); it('shows steps with duration and tips', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await waitFor(() => { expect(screen.getByText('Boil water')).toBeInTheDocument(); expect(screen.getByText('Cook pasta')).toBeInTheDocument(); expect(screen.getByText('Tip: Al dente')).toBeInTheDocument(); expect(screen.getByText('10 min')).toBeInTheDocument(); }); }); it('shows nutritional warnings', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await waitFor(() => { expect(screen.getByText('Nutritional alerts')).toBeInTheDocument(); }); }); it('shows nutrition panel', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await waitFor(() => { expect(screen.getByText('450 kcal')).toBeInTheDocument(); expect(screen.getByText('25.0g')).toBeInTheDocument(); }); }); it('shows edit link', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await waitFor(() => { expect(screen.getByText('Edit')).toBeInTheDocument(); }); }); it('handles non-Error fetch failure', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockRejectedValue('string error'); render(); await waitFor(() => { expect(screen.getByText('Failed to load recipe')).toBeInTheDocument(); }); }); it('renders recipe without description', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue({ ...SAMPLE_RECIPE, description: undefined, warnings: [], isFavorite: false, }); render(); await waitFor(() => { expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); expect(screen.queryByText('Starred')).not.toBeInTheDocument(); }); }); it('scales the recipe servings', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); mockScaleRecipe.mockResolvedValue({ ...SAMPLE_RECIPE, servings: 8, ingredients: SAMPLE_RECIPE.ingredients.map(i => ({ ...i, quantity: i.quantity * 2 })), }); render(); await screen.findByText('Spaghetti Bolognese'); const scaleInput = screen.getByRole('spinbutton'); fireEvent.change(scaleInput, { target: { value: '8' } }); const scaleBtn = screen.getByRole('button', { name: /Scale/i }); fireEvent.click(scaleBtn); await waitFor(() => { expect(mockScaleRecipe).toHaveBeenCalledWith('hh1', 'r1', { targetServings: 8 }); }); // Test resetting back to original const resetBtn = screen.getByRole('button', { name: /Reset to original/i }); fireEvent.click(resetBtn); expect((scaleInput as HTMLInputElement).value).toBe('4'); }); it('handles scale recipe failure gracefully', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); mockScaleRecipe.mockRejectedValue(new Error('Scale failed')); render(); await screen.findByText('Spaghetti Bolognese'); const scaleInput = screen.getByRole('spinbutton'); fireEvent.change(scaleInput, { target: { value: '6' } }); const scaleBtn = screen.getByRole('button', { name: /Scale/i }); fireEvent.click(scaleBtn); await waitFor(() => { expect(screen.getByText('Scale failed')).toBeInTheDocument(); }); }); it('skips scaling if servings did not change', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE); render(); await screen.findByText('Spaghetti Bolognese'); const scaleBtn = screen.getByRole('button', { name: /Scale/i }); fireEvent.click(scaleBtn); expect(mockScaleRecipe).not.toHaveBeenCalled(); }); });