2026-05-14 14:47:23 +09:00
|
|
|
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>,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import RecipesPage from '../../../../src/app/(dashboard)/recipes/page';
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
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 }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|