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 { mockListRecipes, mockDeleteRecipe } = vi.hoisted(() => ({ mockListRecipes: vi.fn(), mockDeleteRecipe: vi.fn(), })); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/services/recipes', () => ({ listRecipes: mockListRecipes, deleteRecipe: mockDeleteRecipe, createRecipe: vi.fn(), updateRecipe: vi.fn(), getRecipe: vi.fn(), scaleRecipe: vi.fn(), importRecipeFromText: vi.fn(), importRecipeFromUrl: vi.fn(), listRecipesByProduct: vi.fn(), })); vi.mock('next/link', () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any default: (props: any) => {props.children}, })); import RecipesPage from '../../../../src/app/(dashboard)/recipes/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', 'comfort'], isFavorite: true, ingredients: [ { productId: 'p1', productName: 'Spaghetti', quantity: 400, unit: 'g', isOptional: false, }, ], steps: [{ order: 1, instruction: 'Boil water' }], perServingNutrition: { calories: 450, protein: 25, carbs: 55, fat: 12, fiber: 3, sugar: 5, sodium: 400, }, 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('RecipesPage', () => { it('shows loading skeleton when session is loading', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: true }); render(); expect(screen.getByText('Recipes')).toBeInTheDocument(); expect(screen.queryByText('Search recipes...')).not.toBeInTheDocument(); }); it('shows household prompt when no householdId', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false }); render(); expect(screen.getByText(/create or join a household/)).toBeInTheDocument(); }); it('renders recipe list when household exists', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); }); }); it('shows empty state when no recipes', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText(/No recipes yet/)).toBeInTheDocument(); }); }); it('shows nutrition info on recipe card', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('450 kcal')).toBeInTheDocument(); expect(screen.getByText('4 servings')).toBeInTheDocument(); }); }); it('shows time on recipe card', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('45 min')).toBeInTheDocument(); }); }); it('shows warning labels', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('High cal')).toBeInTheDocument(); }); }); it('shows tags on recipe card', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('pasta')).toBeInTheDocument(); expect(screen.getByText('comfort')).toBeInTheDocument(); }); }); it('shows favorite star', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { // Star character is rendered for favorites expect(screen.getByText('Spaghetti Bolognese').closest('a')).toBeInTheDocument(); }); }); it('shows edit link', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('Edit')).toBeInTheDocument(); }); }); it('handles delete with confirmation', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); mockDeleteRecipe.mockResolvedValue(undefined); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Delete')); await waitFor(() => { expect(mockDeleteRecipe).toHaveBeenCalledWith('hh1', 'r1'); }); }); it('does not delete when confirm is cancelled', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); vi.spyOn(window, 'confirm').mockReturnValue(false); render(); await waitFor(() => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Delete')); expect(mockDeleteRecipe).not.toHaveBeenCalled(); }); it('shows error when fetch fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockRejectedValue(new Error('Network error')); render(); await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); }); it('shows filter empty state message', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByPlaceholderText('Cuisine...')).toBeInTheDocument(); }); fireEvent.change(screen.getByPlaceholderText('Cuisine...'), { target: { value: 'Thai' }, }); await waitFor(() => { expect(screen.getByText(/No recipes match your filters/)).toBeInTheDocument(); }); }); it('shows recipe without totalTime using prep+cook', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [{ ...SAMPLE_RECIPE, totalTime: undefined }], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('45 min')).toBeInTheDocument(); }); }); it('formats hours correctly', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [{ ...SAMPLE_RECIPE, totalTime: 90 }], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('1h 30m')).toBeInTheDocument(); }); }); it('formats exact hours', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [{ ...SAMPLE_RECIPE, totalTime: 120 }], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('2h')).toBeInTheDocument(); }); }); it('handles delete error', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); mockDeleteRecipe.mockRejectedValue(new Error('Delete failed')); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Delete')); await waitFor(() => { expect(screen.getByText('Delete failed')).toBeInTheDocument(); }); }); it('handles non-Error delete failure', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [SAMPLE_RECIPE], pagination: { cursor: null, hasMore: false }, }); mockDeleteRecipe.mockRejectedValue('string error'); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Delete')); await waitFor(() => { expect(screen.getByText('Failed to delete recipe')).toBeInTheDocument(); }); }); it('shows + New Recipe link', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('+ New Recipe')).toBeInTheDocument(); }); }); it('recipe with no warnings renders without warning badges', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [{ ...SAMPLE_RECIPE, warnings: [], tags: [] }], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); expect(screen.queryByText('High cal')).not.toBeInTheDocument(); }); }); it('toggles favorites checkbox', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRecipes.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByLabelText('Favorites')).toBeInTheDocument(); }); fireEvent.click(screen.getByLabelText('Favorites')); await waitFor(() => { expect(mockListRecipes).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ isFavorite: true }), ); }); }); });