Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
291
packages/web/tests/app/(dashboard)/recipes/[id]/page.test.tsx
Normal file
291
packages/web/tests/app/(dashboard)/recipes/[id]/page.test.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
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) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nutritional alerts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutrition panel', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
|
||||
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(<RecipeDetailPage />);
|
||||
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(<RecipeDetailPage />);
|
||||
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(<RecipeDetailPage />);
|
||||
await screen.findByText('Spaghetti Bolognese');
|
||||
|
||||
const scaleBtn = screen.getByRole('button', { name: /Scale/i });
|
||||
fireEvent.click(scaleBtn);
|
||||
|
||||
expect(mockScaleRecipe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue