Tests refactor

This commit is contained in:
Aerilyn Weber 2026-05-19 11:06:03 +09:00
parent 245520fb50
commit 99134d8556
165 changed files with 911 additions and 531 deletions

View file

@ -0,0 +1,262 @@
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>,
}));
import { RecipeEditor } from '../../../../src/app/(dashboard)/recipes/RecipeEditor';
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();
});
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);
});
});

View file

@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockGetRecipe } = vi.hoisted(() => ({
mockGetRecipe: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
getRecipe: mockGetRecipe,
createRecipe: vi.fn(),
updateRecipe: vi.fn(),
listRecipes: vi.fn(),
deleteRecipe: vi.fn(),
scaleRecipe: 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 EditRecipePage from '../../../../../../src/app/(dashboard)/recipes/[id]/edit/page';
beforeEach(() => vi.clearAllMocks());
const SAMPLE_RECIPE = {
_id: 'r1',
name: 'Pasta',
description: '',
servings: 4,
tags: [],
isFavorite: false,
ingredients: [
{ productId: 'p1', productName: 'Flour', quantity: 200, unit: 'g', isOptional: false },
],
steps: [{ order: 1, instruction: 'Mix' }],
perServingNutrition: { calories: 200, protein: 5, carbs: 30, fat: 4 },
totalNutrition: { calories: 800, protein: 20, carbs: 120, fat: 16 },
warnings: [],
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
};
describe('EditRecipePage', () => {
it('shows loading state', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<EditRecipePage />);
expect(screen.getByText('Edit Recipe')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows error when fetch fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockRejectedValue(new Error('Not found'));
render(<EditRecipePage />);
await waitFor(() => {
expect(screen.getByText('Not found')).toBeInTheDocument();
});
});
it('renders editor when recipe loads', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
render(<EditRecipePage />);
await waitFor(() => {
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
expect(screen.getByText('Save changes')).toBeInTheDocument();
});
});
it('shows fallback when recipe is not found', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockGetRecipe.mockRejectedValue('unexpected');
render(<EditRecipePage />);
await waitFor(() => {
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
});
});
});

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

View file

@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/recipes', () => ({
createRecipe: vi.fn(),
updateRecipe: vi.fn(),
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: 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 NewRecipePage from '../../../../../src/app/(dashboard)/recipes/new/page';
beforeEach(() => vi.clearAllMocks());
describe('NewRecipePage', () => {
it('shows loading state', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<NewRecipePage />);
expect(screen.getByText('New Recipe')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<NewRecipePage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders editor when household exists', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render(<NewRecipePage />);
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
expect(screen.getByText('Create recipe')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,423 @@
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) => <a href={props.href}>{props.children}</a>,
}));
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
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(<RecipesPage />);
await waitFor(() => {
expect(screen.getByLabelText('Favorites')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Favorites'));
await waitFor(() => {
expect(mockListRecipes).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ isFavorite: true }),
);
});
});
});