diff --git a/.wip/AddMealModal.test.tsx b/.wip/AddMealModal.test.tsx new file mode 100644 index 0000000..506319e --- /dev/null +++ b/.wip/AddMealModal.test.tsx @@ -0,0 +1,413 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import AddMealModal from './AddMealModal'; +import { mealPlansApi, recipesApi } from '../../services/api'; +import { MealType } from '@basil/shared'; + +// Mock API +vi.mock('../../services/api', () => ({ + mealPlansApi: { + getByDate: vi.fn(), + create: vi.fn(), + addMeal: vi.fn(), + }, + recipesApi: { + getAll: vi.fn(), + }, +})); + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('AddMealModal', () => { + const mockDate = new Date('2025-01-15'); + const mockOnClose = vi.fn(); + const mockOnMealAdded = vi.fn(); + + const mockRecipes = [ + { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes', + servings: 4, + images: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'r2', + title: 'Sandwich', + description: 'Classic sandwich', + servings: 2, + images: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(recipesApi.getAll).mockResolvedValue({ + data: mockRecipes, + pagination: { total: 2, page: 1, limit: 100, pages: 1 }, + } as any); + }); + + it('should render modal when open', async () => { + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Add Meal')).toBeInTheDocument(); + }); + }); + + it('should fetch and display recipes', async () => { + renderWithRouter( + + ); + + await waitFor(() => { + expect(recipesApi.getAll).toHaveBeenCalled(); + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByText('Sandwich')).toBeInTheDocument(); + }); + }); + + it('should filter recipes based on search input', async () => { + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText(/search for a recipe/i); + fireEvent.change(searchInput, { target: { value: 'Sandwich' } }); + + await waitFor(() => { + expect(screen.queryByText('Pancakes')).not.toBeInTheDocument(); + expect(screen.getByText('Sandwich')).toBeInTheDocument(); + }); + }); + + it('should select recipe on click', async () => { + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + const recipeItem = screen.getByText('Pancakes').closest('.recipe-item'); + if (recipeItem) { + fireEvent.click(recipeItem); + + await waitFor(() => { + expect(recipeItem).toHaveClass('selected'); + }); + } + }); + + it('should create new meal plan and add meal', async () => { + vi.mocked(mealPlansApi.getByDate).mockResolvedValue({ data: null }); + vi.mocked(mealPlansApi.create).mockResolvedValue({ + data: { id: 'mp1', date: mockDate, meals: [] }, + } as any); + vi.mocked(mealPlansApi.addMeal).mockResolvedValue({ data: {} } as any); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + // Select recipe + const recipeItem = screen.getByText('Pancakes').closest('.recipe-item'); + if (recipeItem) { + fireEvent.click(recipeItem); + } + + // Set servings + const servingsInput = screen.getByLabelText(/servings/i); + fireEvent.change(servingsInput, { target: { value: '6' } }); + + // Submit form + const submitButton = screen.getByRole('button', { name: 'Add Meal' }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mealPlansApi.getByDate).toHaveBeenCalledWith('2025-01-15'); + expect(mealPlansApi.create).toHaveBeenCalledWith({ date: '2025-01-15' }); + expect(mealPlansApi.addMeal).toHaveBeenCalledWith('mp1', { + mealType: MealType.BREAKFAST, + recipeId: 'r1', + servings: 6, + notes: undefined, + }); + expect(mockOnMealAdded).toHaveBeenCalled(); + }); + }); + + it('should add meal to existing meal plan', async () => { + vi.mocked(mealPlansApi.getByDate).mockResolvedValue({ + data: { id: 'mp1', date: mockDate, meals: [] }, + } as any); + vi.mocked(mealPlansApi.addMeal).mockResolvedValue({ data: {} } as any); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + // Select recipe + const recipeCard = screen.getByText('Sandwich').closest('.recipe-item'); + if (recipeCard) { + fireEvent.click(recipeCard); + } + + // Submit form + const submitButton = screen.getByRole('button', { name: 'Add Meal' }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mealPlansApi.getByDate).toHaveBeenCalledWith('2025-01-15'); + expect(mealPlansApi.create).not.toHaveBeenCalled(); + expect(mealPlansApi.addMeal).toHaveBeenCalledWith('mp1', { + mealType: MealType.LUNCH, + recipeId: 'r2', + servings: 2, + notes: undefined, + }); + }); + }); + + it('should include notes when provided', async () => { + vi.mocked(mealPlansApi.getByDate).mockResolvedValue({ + data: { id: 'mp1', date: mockDate, meals: [] }, + } as any); + vi.mocked(mealPlansApi.addMeal).mockResolvedValue({ data: {} } as any); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + // Select recipe + const recipeCard = screen.getByText('Pancakes').closest('.recipe-item'); + if (recipeCard) { + fireEvent.click(recipeCard); + } + + // Add notes + const notesInput = screen.getByLabelText(/notes \(optional\)/i); + fireEvent.change(notesInput, { target: { value: 'Extra syrup please' } }); + + // Submit form + const submitButton = screen.getByRole('button', { name: 'Add Meal' }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mealPlansApi.addMeal).toHaveBeenCalledWith('mp1', { + mealType: MealType.DINNER, + recipeId: 'r1', + servings: 4, + notes: 'Extra syrup please', + }); + }); + }); + + it('should close modal on cancel', async () => { + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText(/add meal/i)).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should display error message on save failure', async () => { + vi.mocked(mealPlansApi.getByDate).mockRejectedValue(new Error('Network error')); + + // Mock window.alert + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + // Select recipe + const recipeCard = screen.getByText('Pancakes').closest('.recipe-item'); + if (recipeCard) { + fireEvent.click(recipeCard); + } + + // Submit form + const submitButton = screen.getByRole('button', { name: 'Add Meal' }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith('Failed to add meal'); + }); + + alertMock.mockRestore(); + }); + + it('should show alert if no recipe selected', async () => { + // Mock window.alert + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Add Meal')).toBeInTheDocument(); + }); + + // Submit form without selecting recipe + const submitButton = screen.getByRole('button', { name: 'Add Meal' }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith('Please select a recipe'); + }); + + alertMock.mockRestore(); + }); + + it('should handle empty recipe list', async () => { + vi.mocked(recipesApi.getAll).mockResolvedValue({ + data: [], + pagination: { total: 0, page: 1, limit: 100, pages: 0 }, + } as any); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText(/no recipes found/i)).toBeInTheDocument(); + }); + }); + + it('should change meal type via dropdown', async () => { + vi.mocked(mealPlansApi.getByDate).mockResolvedValue({ + data: { id: 'mp1', date: mockDate, meals: [] }, + } as any); + vi.mocked(mealPlansApi.addMeal).mockResolvedValue({ data: {} } as any); + + renderWithRouter( + + ); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + + // Change meal type + const mealTypeSelect = screen.getByLabelText(/meal type/i); + fireEvent.change(mealTypeSelect, { target: { value: MealType.BREAKFAST } }); + + // Select recipe and submit + const recipeCard = screen.getByText('Pancakes').closest('.recipe-item'); + if (recipeCard) { + fireEvent.click(recipeCard); + } + + const submitButton = screen.getByRole('button', { name: 'Add Meal' }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mealPlansApi.addMeal).toHaveBeenCalledWith('mp1', { + mealType: MealType.BREAKFAST, + recipeId: 'r1', + servings: 4, + notes: undefined, + }); + }); + }); +}); diff --git a/.wip/CalendarView.test.tsx b/.wip/CalendarView.test.tsx new file mode 100644 index 0000000..195cd50 --- /dev/null +++ b/.wip/CalendarView.test.tsx @@ -0,0 +1,450 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import CalendarView from './CalendarView'; +import { MealPlan, MealType } from '@basil/shared'; + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('CalendarView', () => { + const mockOnAddMeal = vi.fn(); + const mockOnRemoveMeal = vi.fn(); + const currentDate = new Date('2025-01-15'); // Middle of January 2025 + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render calendar grid with correct number of days', () => { + renderWithRouter( + + ); + + // Should have 7 weekday headers + expect(screen.getByText('Sun')).toBeInTheDocument(); + expect(screen.getByText('Mon')).toBeInTheDocument(); + expect(screen.getByText('Sat')).toBeInTheDocument(); + + // Calendar should have cells (31 days + overflow from prev/next months) + const cells = document.querySelectorAll('.calendar-cell'); + expect(cells.length).toBeGreaterThanOrEqual(31); + }); + + it('should highlight current day', () => { + // Set current date to today + const today = new Date(); + + renderWithRouter( + + ); + + const todayCells = document.querySelectorAll('.calendar-cell.today'); + expect(todayCells.length).toBe(1); + }); + + it('should display meals for each day', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByText(MealType.BREAKFAST)).toBeInTheDocument(); + }); + + it('should group meals by type', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Breakfast item', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'm2', + mealPlanId: 'mp1', + mealType: MealType.LUNCH, + order: 0, + servings: 2, + recipe: { + mealId: 'm2', + recipeId: 'r2', + recipe: { + id: 'r2', + title: 'Sandwich', + description: 'Lunch item', + servings: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByText('Sandwich')).toBeInTheDocument(); + expect(screen.getByText(MealType.BREAKFAST)).toBeInTheDocument(); + expect(screen.getByText(MealType.LUNCH)).toBeInTheDocument(); + }); + + it('should show "Add Meal" button for each day', () => { + renderWithRouter( + + ); + + const addButtons = screen.getAllByText('+ Add Meal'); + expect(addButtons.length).toBeGreaterThan(0); + }); + + it('should call onAddMeal with correct date', () => { + renderWithRouter( + + ); + + const addButtons = screen.getAllByText('+ Add Meal'); + fireEvent.click(addButtons[0]); + + expect(mockOnAddMeal).toHaveBeenCalled(); + const calledDate = mockOnAddMeal.mock.calls[0][0]; + expect(calledDate).toBeInstanceOf(Date); + expect(mockOnAddMeal.mock.calls[0][1]).toBe(MealType.DINNER); + }); + + it('should handle months with different day counts', () => { + // February 2025 has 28 days + const februaryDate = new Date('2025-02-15'); + + renderWithRouter( + + ); + + const cells = document.querySelectorAll('.calendar-cell'); + expect(cells.length).toBeGreaterThanOrEqual(28); + }); + + it('should render overflow days from previous/next months', () => { + renderWithRouter( + + ); + + const otherMonthCells = document.querySelectorAll('.calendar-cell.other-month'); + expect(otherMonthCells.length).toBeGreaterThan(0); + }); + + it('should display day numbers correctly', () => { + renderWithRouter( + + ); + + // Should find day 15 (current date) + const dayNumbers = document.querySelectorAll('.date-number'); + const day15 = Array.from(dayNumbers).find(el => el.textContent === '15'); + expect(day15).toBeInTheDocument(); + }); + + it('should handle empty meal plans', () => { + renderWithRouter( + + ); + + // Should not find any meal cards + expect(screen.queryByText('Pancakes')).not.toBeInTheDocument(); + + // But should still show add buttons + const addButtons = screen.getAllByText('+ Add Meal'); + expect(addButtons.length).toBeGreaterThan(0); + }); + + it('should call onRemoveMeal when meal card remove button clicked', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + const removeButton = screen.getByTitle('Remove meal'); + fireEvent.click(removeButton); + + expect(mockOnRemoveMeal).toHaveBeenCalledWith('m1'); + }); + + it('should display multiple meals of same type', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.DINNER, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Steak', + description: 'Main course', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'm2', + mealPlanId: 'mp1', + mealType: MealType.DINNER, + order: 1, + servings: 4, + recipe: { + mealId: 'm2', + recipeId: 'r2', + recipe: { + id: 'r2', + title: 'Salad', + description: 'Side dish', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Steak')).toBeInTheDocument(); + expect(screen.getByText('Salad')).toBeInTheDocument(); + // Should only show DINNER label once, not twice + const dinnerLabels = screen.getAllByText(MealType.DINNER); + expect(dinnerLabels.length).toBe(1); + }); + + it('should render meals in compact mode', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + notes: 'Special notes', + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes with maple syrup', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + // Compact mode should not show notes or description + expect(screen.queryByText(/Special notes/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Delicious pancakes with/)).not.toBeInTheDocument(); + }); +}); diff --git a/.wip/MealCard.test.tsx b/.wip/MealCard.test.tsx new file mode 100644 index 0000000..95460b6 --- /dev/null +++ b/.wip/MealCard.test.tsx @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import MealCard from './MealCard'; +import { Meal, MealType } from '@basil/shared'; + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('MealCard', () => { + const mockMeal: Meal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + notes: 'Extra syrup', + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious fluffy pancakes with maple syrup and butter', + servings: 4, + totalTime: 30, + imageUrl: '/uploads/pancakes.jpg', + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockOnRemove = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render in compact mode', () => { + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByAltText('Pancakes')).toBeInTheDocument(); + // Should not show description in compact mode + expect(screen.queryByText(/Delicious fluffy/)).not.toBeInTheDocument(); + }); + + it('should render in full mode', () => { + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByText(/Delicious fluffy pancakes/)).toBeInTheDocument(); + expect(screen.getByText(/30 min/)).toBeInTheDocument(); + expect(screen.getByText(/4 servings/)).toBeInTheDocument(); + }); + + it('should display recipe image', () => { + renderWithRouter( + + ); + + const image = screen.getByAltText('Pancakes') as HTMLImageElement; + expect(image).toBeInTheDocument(); + expect(image.src).toContain('/uploads/pancakes.jpg'); + }); + + it('should display servings if overridden', () => { + const mealWithServings = { + ...mockMeal, + servings: 8, + }; + + renderWithRouter( + + ); + + expect(screen.getByText(/8 servings/)).toBeInTheDocument(); + }); + + it('should display notes if present', () => { + renderWithRouter( + + ); + + expect(screen.getByText(/Notes:/)).toBeInTheDocument(); + expect(screen.getByText(/Extra syrup/)).toBeInTheDocument(); + }); + + it('should call onRemove when delete button clicked', () => { + renderWithRouter( + + ); + + const removeButton = screen.getByTitle('Remove meal'); + fireEvent.click(removeButton); + + expect(mockOnRemove).toHaveBeenCalled(); + }); + + it('should navigate to recipe on click', () => { + renderWithRouter( + + ); + + const card = screen.getByText('Pancakes').closest('.meal-card-content'); + if (card) { + fireEvent.click(card); + } + + expect(mockNavigate).toHaveBeenCalledWith('/recipes/r1'); + }); + + it('should handle missing recipe data gracefully', () => { + const mealWithoutRecipe = { + ...mockMeal, + recipe: undefined, + } as Meal; + + const { container } = renderWithRouter( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should handle recipe without image', () => { + const mealWithoutImage = { + ...mockMeal, + recipe: { + ...mockMeal.recipe!, + recipe: { + ...mockMeal.recipe!.recipe, + imageUrl: undefined, + }, + }, + }; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('should handle recipe without description', () => { + const mealWithoutDescription = { + ...mockMeal, + recipe: { + ...mockMeal.recipe!, + recipe: { + ...mockMeal.recipe!.recipe, + description: undefined, + }, + }, + }; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.queryByClassName('meal-card-description')).not.toBeInTheDocument(); + }); + + it('should handle recipe without total time', () => { + const mealWithoutTime = { + ...mockMeal, + recipe: { + ...mockMeal.recipe!, + recipe: { + ...mockMeal.recipe!.recipe, + totalTime: undefined, + }, + }, + }; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.queryByText(/min/)).not.toBeInTheDocument(); + }); + + it('should handle meal without notes', () => { + const mealWithoutNotes = { + ...mockMeal, + notes: undefined, + }; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.queryByText(/Notes:/)).not.toBeInTheDocument(); + }); + + it('should handle meal without servings', () => { + const mealWithoutServings = { + ...mockMeal, + servings: undefined, + }; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.queryByText(/servings/)).not.toBeInTheDocument(); + }); + + it('should truncate long description', () => { + const longDescription = 'A'.repeat(150); + const mealWithLongDescription = { + ...mockMeal, + recipe: { + ...mockMeal.recipe!, + recipe: { + ...mockMeal.recipe!.recipe, + description: longDescription, + }, + }, + }; + + renderWithRouter( + + ); + + const description = screen.getByText(/A+\.\.\./); + expect(description.textContent?.length).toBeLessThanOrEqual(104); // 100 chars + "..." + }); + + it('should stop propagation when clicking remove button', () => { + renderWithRouter( + + ); + + const removeButton = screen.getByTitle('Remove meal'); + fireEvent.click(removeButton); + + // Should not navigate when clicking remove button + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockOnRemove).toHaveBeenCalled(); + }); +}); diff --git a/.wip/MealPlanner.test.tsx b/.wip/MealPlanner.test.tsx new file mode 100644 index 0000000..c0f86b8 --- /dev/null +++ b/.wip/MealPlanner.test.tsx @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import MealPlanner from './MealPlanner'; +import { mealPlansApi } from '../services/api'; + +// Mock API +vi.mock('../services/api', () => ({ + mealPlansApi: { + getAll: vi.fn(), + addMeal: vi.fn(), + removeMeal: vi.fn(), + generateShoppingList: vi.fn(), + }, + recipesApi: { + getAll: vi.fn(), + }, +})); + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('MealPlanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render loading state initially', () => { + vi.mocked(mealPlansApi.getAll).mockImplementation( + () => new Promise(() => {}) // Never resolves + ); + + renderWithRouter(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('should fetch meal plans on mount', async () => { + const mockMealPlans = [ + { + id: 'mp1', + date: '2025-01-15', + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: mockMealPlans }); + + renderWithRouter(); + + await waitFor(() => { + expect(mealPlansApi.getAll).toHaveBeenCalled(); + }); + }); + + it('should display error message on API failure', async () => { + vi.mocked(mealPlansApi.getAll).mockRejectedValue(new Error('Network error')); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + }); + + it('should toggle between calendar and weekly views', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Find view toggle buttons + const viewButtons = screen.getAllByRole('button'); + const weeklyButton = viewButtons.find(btn => btn.textContent?.includes('Weekly')); + + if (weeklyButton) { + fireEvent.click(weeklyButton); + + await waitFor(() => { + // Should now show weekly view + expect(mealPlansApi.getAll).toHaveBeenCalledTimes(2); // Once for initial, once for view change + }); + } + }); + + it('should navigate to previous month', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Find and click previous button + const prevButton = screen.getByRole('button', { name: /previous/i }); + fireEvent.click(prevButton); + + await waitFor(() => { + expect(mealPlansApi.getAll).toHaveBeenCalledTimes(2); + }); + }); + + it('should navigate to next month', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Find and click next button + const nextButton = screen.getByRole('button', { name: /next/i }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mealPlansApi.getAll).toHaveBeenCalledTimes(2); + }); + }); + + it('should navigate to today', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Navigate to a different month first + const nextButton = screen.getByRole('button', { name: /next/i }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mealPlansApi.getAll).toHaveBeenCalledTimes(2); + }); + + // Then click "Today" button + const todayButton = screen.getByRole('button', { name: /today/i }); + fireEvent.click(todayButton); + + await waitFor(() => { + expect(mealPlansApi.getAll).toHaveBeenCalledTimes(3); + }); + }); + + it('should display meal plans in calendar view', async () => { + const mockMealPlans = [ + { + id: 'mp1', + date: '2025-01-15', + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: mockMealPlans }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + }); + }); + + it('should open add meal modal when clicking add meal button', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Find and click an "Add Meal" button + const addButtons = screen.getAllByText(/add meal/i); + if (addButtons.length > 0) { + fireEvent.click(addButtons[0]); + + await waitFor(() => { + // Modal should be visible + expect(screen.getByRole('dialog') || screen.getByTestId('add-meal-modal')).toBeInTheDocument(); + }); + } + }); + + it('should open shopping list modal when clicking shopping list button', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Find and click shopping list button + const shoppingListButton = screen.getByRole('button', { name: /shopping list/i }); + fireEvent.click(shoppingListButton); + + await waitFor(() => { + // Modal should be visible + expect(screen.getByRole('dialog') || screen.getByTestId('shopping-list-modal')).toBeInTheDocument(); + }); + }); + + it('should refresh data after closing modal', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + const initialCallCount = vi.mocked(mealPlansApi.getAll).mock.calls.length; + + // Open and close add meal modal + const addButtons = screen.getAllByText(/add meal/i); + if (addButtons.length > 0) { + fireEvent.click(addButtons[0]); + + // Find close button in modal + const cancelButton = await screen.findByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + await waitFor(() => { + // Should fetch again after closing modal + expect(mealPlansApi.getAll).toHaveBeenCalledTimes(initialCallCount + 1); + }); + } + }); + + it('should handle empty state', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Should show calendar with no meals + expect(screen.queryByText('Pancakes')).not.toBeInTheDocument(); + }); + + it('should correctly calculate date range for current month', async () => { + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: [] }); + + renderWithRouter(); + + await waitFor(() => { + expect(mealPlansApi.getAll).toHaveBeenCalled(); + }); + + // Check that the API was called with dates from the current month + const callArgs = vi.mocked(mealPlansApi.getAll).mock.calls[0][0]; + expect(callArgs).toHaveProperty('startDate'); + expect(callArgs).toHaveProperty('endDate'); + + // startDate should be the first of the month + const startDate = new Date(callArgs.startDate); + expect(startDate.getDate()).toBe(1); + + // endDate should be the last day of the month + const endDate = new Date(callArgs.endDate); + expect(endDate.getDate()).toBeGreaterThan(27); // Last day is at least 28 + }); + + it('should group meals by type', async () => { + const mockMealPlans = [ + { + id: 'mp1', + date: '2025-01-15', + notes: 'Test plan', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Breakfast item', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'm2', + mealPlanId: 'mp1', + mealType: 'LUNCH', + order: 0, + servings: 4, + recipe: { + mealId: 'm2', + recipeId: 'r2', + recipe: { + id: 'r2', + title: 'Sandwich', + description: 'Lunch item', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + vi.mocked(mealPlansApi.getAll).mockResolvedValue({ data: mockMealPlans }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByText('Sandwich')).toBeInTheDocument(); + }); + }); +}); diff --git a/.wip/ShoppingListModal.test.tsx b/.wip/ShoppingListModal.test.tsx new file mode 100644 index 0000000..b18e237 --- /dev/null +++ b/.wip/ShoppingListModal.test.tsx @@ -0,0 +1,410 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ShoppingListModal from './ShoppingListModal'; +import { mealPlansApi } from '../../services/api'; + +// Mock API +vi.mock('../../services/api', () => ({ + mealPlansApi: { + generateShoppingList: vi.fn(), + }, +})); + +describe('ShoppingListModal', () => { + const mockOnClose = vi.fn(); + const mockDateRange = { + startDate: new Date('2025-01-01'), + endDate: new Date('2025-01-31'), + }; + + const mockShoppingList = { + items: [ + { + ingredientName: 'flour', + totalAmount: 2, + unit: 'cups', + recipes: ['Pancakes', 'Cookies'], + }, + { + ingredientName: 'sugar', + totalAmount: 1.5, + unit: 'cups', + recipes: ['Cookies'], + }, + ], + dateRange: { + start: '2025-01-01', + end: '2025-01-31', + }, + recipeCount: 2, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render when open', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Shopping List')).toBeInTheDocument(); + }); + }); + + it('should fetch shopping list on mount', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(mealPlansApi.generateShoppingList).toHaveBeenCalledWith({ + startDate: '2025-01-01', + endDate: '2025-01-31', + }); + }); + }); + + it('should display ingredients grouped by name and unit', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('flour')).toBeInTheDocument(); + expect(screen.getByText('sugar')).toBeInTheDocument(); + expect(screen.getByText(/2 cups/)).toBeInTheDocument(); + expect(screen.getByText(/1.5 cups/)).toBeInTheDocument(); + }); + }); + + it('should show recipe sources per ingredient', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Used in: Pancakes, Cookies/)).toBeInTheDocument(); + expect(screen.getByText(/Used in: Cookies/)).toBeInTheDocument(); + }); + }); + + it('should allow checking/unchecking items', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('flour')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(2); + + fireEvent.click(checkboxes[0]); + expect(checkboxes[0]).toBeChecked(); + + fireEvent.click(checkboxes[0]); + expect(checkboxes[0]).not.toBeChecked(); + }); + + it('should copy to clipboard when clicked', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + // Mock clipboard API + const writeTextMock = vi.fn(); + Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, + }); + + // Mock window.alert + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('flour')).toBeInTheDocument(); + }); + + const copyButton = screen.getByText('Copy to Clipboard'); + fireEvent.click(copyButton); + + expect(writeTextMock).toHaveBeenCalledWith( + 'flour: 2 cups\nsugar: 1.5 cups' + ); + expect(alertMock).toHaveBeenCalledWith('Shopping list copied to clipboard!'); + + alertMock.mockRestore(); + }); + + it('should handle print functionality', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + // Mock window.print + const printMock = vi.fn(); + window.print = printMock; + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('flour')).toBeInTheDocument(); + }); + + const printButton = screen.getByText('Print'); + fireEvent.click(printButton); + + expect(printMock).toHaveBeenCalled(); + }); + + it('should display loading state while generating', () => { + vi.mocked(mealPlansApi.generateShoppingList).mockImplementation( + () => new Promise(() => {}) // Never resolves + ); + + render( + + ); + + expect(screen.getByText(/Generating shopping list/i)).toBeInTheDocument(); + }); + + it('should display error on API failure', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockRejectedValue( + new Error('Network error') + ); + + // Mock window.alert + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + render( + + ); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith('Failed to generate shopping list'); + }); + + alertMock.mockRestore(); + }); + + it('should display empty state when no items', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: { + items: [], + dateRange: { start: '2025-01-01', end: '2025-01-31' }, + recipeCount: 0, + }, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/No meals planned for this date range/)).toBeInTheDocument(); + }); + }); + + it('should display recipe count and date range', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/2/)).toBeInTheDocument(); // recipe count + expect(screen.getByText(/1\/1\/2025/)).toBeInTheDocument(); + expect(screen.getByText(/1\/31\/2025/)).toBeInTheDocument(); + }); + }); + + it('should close modal when close button clicked', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Shopping List')).toBeInTheDocument(); + }); + + const closeButton = screen.getByText('✕'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should close modal when clicking overlay', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Shopping List')).toBeInTheDocument(); + }); + + const overlay = document.querySelector('.modal-overlay'); + if (overlay) { + fireEvent.click(overlay); + } + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should not close modal when clicking content', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Shopping List')).toBeInTheDocument(); + }); + + const modalContent = document.querySelector('.modal-content'); + if (modalContent) { + fireEvent.click(modalContent); + } + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should allow changing date range', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('flour')).toBeInTheDocument(); + }); + + const startDateInput = screen.getByLabelText('From') as HTMLInputElement; + const endDateInput = screen.getByLabelText('To') as HTMLInputElement; + + expect(startDateInput.value).toBe('2025-01-01'); + expect(endDateInput.value).toBe('2025-01-31'); + + fireEvent.change(startDateInput, { target: { value: '2025-01-15' } }); + fireEvent.change(endDateInput, { target: { value: '2025-01-20' } }); + + expect(startDateInput.value).toBe('2025-01-15'); + expect(endDateInput.value).toBe('2025-01-20'); + }); + + it('should regenerate list when regenerate button clicked', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: mockShoppingList, + } as any); + + render( + + ); + + await waitFor(() => { + expect(mealPlansApi.generateShoppingList).toHaveBeenCalledTimes(1); + }); + + // Change dates + const startDateInput = screen.getByLabelText('From'); + fireEvent.change(startDateInput, { target: { value: '2025-01-10' } }); + + // Click regenerate + const regenerateButton = screen.getByText('Regenerate'); + fireEvent.click(regenerateButton); + + await waitFor(() => { + expect(mealPlansApi.generateShoppingList).toHaveBeenCalledTimes(2); + expect(mealPlansApi.generateShoppingList).toHaveBeenLastCalledWith({ + startDate: '2025-01-10', + endDate: '2025-01-31', + }); + }); + }); + + it('should handle null shopping list data', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: null, + } as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/No meals planned for this date range/)).toBeInTheDocument(); + }); + }); + + it('should not copy to clipboard when no shopping list', async () => { + vi.mocked(mealPlansApi.generateShoppingList).mockResolvedValue({ + data: null, + } as any); + + // Mock clipboard API + const writeTextMock = vi.fn(); + Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/No meals planned/)).toBeInTheDocument(); + }); + + // Copy button should not be visible + expect(screen.queryByText('Copy to Clipboard')).not.toBeInTheDocument(); + }); +}); diff --git a/.wip/WeeklyListView.test.tsx b/.wip/WeeklyListView.test.tsx new file mode 100644 index 0000000..6ef6d1e --- /dev/null +++ b/.wip/WeeklyListView.test.tsx @@ -0,0 +1,467 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import WeeklyListView from './WeeklyListView'; +import { MealPlan, MealType } from '@basil/shared'; + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('WeeklyListView', () => { + const mockOnAddMeal = vi.fn(); + const mockOnRemoveMeal = vi.fn(); + const currentDate = new Date('2025-01-15'); // Wednesday, January 15, 2025 + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render 7 days starting from Sunday', () => { + renderWithRouter( + + ); + + // Should have 7 day sections + const daySections = document.querySelectorAll('.day-section'); + expect(daySections.length).toBe(7); + + // First day should be Sunday (Jan 12, 2025) + expect(screen.getByText(/Sunday, January 12/)).toBeInTheDocument(); + }); + + it('should display meals grouped by type', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Hump day!', + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'm2', + mealPlanId: 'mp1', + mealType: MealType.LUNCH, + order: 0, + servings: 2, + recipe: { + mealId: 'm2', + recipeId: 'r2', + recipe: { + id: 'r2', + title: 'Sandwich', + description: 'Classic sandwich', + servings: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Pancakes')).toBeInTheDocument(); + expect(screen.getByText('Sandwich')).toBeInTheDocument(); + }); + + it('should show "Add Meal" button for each meal type per day', () => { + renderWithRouter( + + ); + + // 7 days × 6 meal types = 42 buttons + const addButtons = screen.getAllByText(/\+ Add/i); + expect(addButtons.length).toBe(7 * 6); + }); + + it('should call onAddMeal with correct date and meal type', () => { + renderWithRouter( + + ); + + const addBreakfastButton = screen.getAllByText(/\+ Add breakfast/i)[0]; + fireEvent.click(addBreakfastButton); + + expect(mockOnAddMeal).toHaveBeenCalled(); + const calledDate = mockOnAddMeal.mock.calls[0][0]; + expect(calledDate).toBeInstanceOf(Date); + expect(mockOnAddMeal.mock.calls[0][1]).toBe(MealType.BREAKFAST); + }); + + it('should display full meal details', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.DINNER, + order: 0, + servings: 4, + notes: 'Extra crispy', + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Fried Chicken', + description: 'Crispy fried chicken with herbs', + servings: 4, + totalTime: 45, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + // Full mode should show description, time, servings + expect(screen.getByText(/Crispy fried chicken/)).toBeInTheDocument(); + expect(screen.getByText(/45 min/)).toBeInTheDocument(); + expect(screen.getByText(/4 servings/)).toBeInTheDocument(); + expect(screen.getByText(/Extra crispy/)).toBeInTheDocument(); + }); + + it('should handle empty days gracefully', () => { + renderWithRouter( + + ); + + // Should show "No meals planned" for each meal type + const noMealsMessages = screen.getAllByText('No meals planned'); + expect(noMealsMessages.length).toBe(7 * 6); // 7 days × 6 meal types + }); + + it('should highlight today', () => { + // Set current date to today + const today = new Date(); + + renderWithRouter( + + ); + + const todayBadge = screen.getByText('Today'); + expect(todayBadge).toBeInTheDocument(); + + const todaySections = document.querySelectorAll('.day-section.today'); + expect(todaySections.length).toBe(1); + }); + + it('should display day notes if present', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + notes: 'Important dinner party!', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText(/Important dinner party!/)).toBeInTheDocument(); + }); + + it('should call onRemoveMeal when meal card remove button clicked', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Pancakes', + description: 'Delicious pancakes', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + const removeButton = screen.getByTitle('Remove meal'); + fireEvent.click(removeButton); + + expect(mockOnRemoveMeal).toHaveBeenCalledWith('m1'); + }); + + it('should display all meal type headers', () => { + renderWithRouter( + + ); + + // Each day should have headers for all 6 meal types + Object.values(MealType).forEach(mealType => { + const headers = screen.getAllByText(mealType); + expect(headers.length).toBe(7); // One per day + }); + }); + + it('should display multiple meals of same type', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.DINNER, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Steak', + description: 'Main course', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'm2', + mealPlanId: 'mp1', + mealType: MealType.DINNER, + order: 1, + servings: 4, + recipe: { + mealId: 'm2', + recipeId: 'r2', + recipe: { + id: 'r2', + title: 'Salad', + description: 'Side dish', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Steak')).toBeInTheDocument(); + expect(screen.getByText('Salad')).toBeInTheDocument(); + }); + + it('should format day header correctly', () => { + renderWithRouter( + + ); + + // Should show full weekday name, month, and day + expect(screen.getByText(/Wednesday, January 15/)).toBeInTheDocument(); + }); + + it('should handle meals without descriptions', () => { + const mockMealPlans: MealPlan[] = [ + { + id: 'mp1', + date: new Date('2025-01-15'), + meals: [ + { + id: 'm1', + mealPlanId: 'mp1', + mealType: MealType.BREAKFAST, + order: 0, + servings: 4, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { + id: 'r1', + title: 'Simple Eggs', + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Simple Eggs')).toBeInTheDocument(); + }); + + it('should show correct meal type labels in add buttons', () => { + renderWithRouter( + + ); + + expect(screen.getAllByText(/\+ Add breakfast/i).length).toBe(7); + expect(screen.getAllByText(/\+ Add lunch/i).length).toBe(7); + expect(screen.getAllByText(/\+ Add dinner/i).length).toBe(7); + expect(screen.getAllByText(/\+ Add snack/i).length).toBe(7); + expect(screen.getAllByText(/\+ Add dessert/i).length).toBe(7); + expect(screen.getAllByText(/\+ Add other/i).length).toBe(7); + }); +}); diff --git a/RECIPE_LIST_ENHANCEMENT_PLAN.md b/RECIPE_LIST_ENHANCEMENT_PLAN.md new file mode 100644 index 0000000..87ac097 --- /dev/null +++ b/RECIPE_LIST_ENHANCEMENT_PLAN.md @@ -0,0 +1,246 @@ +# Recipe List Enhancement Plan + +## Overview +Enhance the All Recipes page (`/srv/docker-compose/basil/packages/web/src/pages/RecipeList.tsx`) with: +- Pagination (12, 24, 48, All items per page) +- Column controls (3, 6, 9 columns) +- Size slider (7 levels: XS to XXL) +- Search by title or tag + +## Current State Analysis +- **Backend**: Already supports `page`, `limit`, `search` params; returns `PaginatedResponse` +- **Frontend**: Currently calls `recipesApi.getAll()` with NO parameters (loads only 20 recipes) +- **Grid**: Uses `repeat(auto-fill, minmax(300px, 1fr))` with 200px image height +- **Missing**: Tag search backend support, pagination UI, display controls + +## Implementation Plan + +### 1. Backend Enhancement - Tag Search +**File**: `packages/api/src/routes/recipes.routes.ts` (around line 105) + +Add tag filtering support: +```typescript +const { page = '1', limit = '20', search, cuisine, category, tag } = req.query; + +// In where clause: +if (tag) { + where.tags = { + some: { + tag: { + name: { equals: tag as string, mode: 'insensitive' } + } + } + }; +} +``` + +### 2. Frontend State Management +**File**: `packages/web/src/pages/RecipeList.tsx` + +Add state variables: +```typescript +// Pagination +const [currentPage, setCurrentPage] = useState(1); +const [itemsPerPage, setItemsPerPage] = useState(24); +const [totalRecipes, setTotalRecipes] = useState(0); + +// Display controls +const [columnCount, setColumnCount] = useState<3 | 6 | 9>(6); +const [cardSize, setCardSize] = useState(3); // 0-6 scale + +// Search +const [searchInput, setSearchInput] = useState(''); +const [debouncedSearch, setDebouncedSearch] = useState(''); +const [searchType, setSearchType] = useState<'title' | 'tag'>('title'); +const [availableTags, setAvailableTags] = useState([]); +``` + +**LocalStorage persistence** for: `itemsPerPage`, `columnCount`, `cardSize` + +**URL params** using `useSearchParams` for: `page`, `limit`, `search`, `type` + +### 3. Size Presets Definition +```typescript +const SIZE_PRESETS = { + 0: { name: 'XS', minWidth: 150, imageHeight: 100 }, + 1: { name: 'S', minWidth: 200, imageHeight: 133 }, + 2: { name: 'M', minWidth: 250, imageHeight: 167 }, + 3: { name: 'Default', minWidth: 300, imageHeight: 200 }, + 4: { name: 'L', minWidth: 350, imageHeight: 233 }, + 5: { name: 'XL', minWidth: 400, imageHeight: 267 }, + 6: { name: 'XXL', minWidth: 500, imageHeight: 333 }, +}; +``` + +### 4. API Integration +Update `loadRecipes` function to pass pagination and search params: +```typescript +const params: any = { + page: currentPage, + limit: itemsPerPage === -1 ? 10000 : itemsPerPage, // -1 = "All" +}; + +if (debouncedSearch) { + if (searchType === 'title') { + params.search = debouncedSearch; + } else { + params.tag = debouncedSearch; + } +} + +const response = await recipesApi.getAll(params); +``` + +### 5. UI Layout Structure +``` +┌─────────────────────────────────────────────────────────┐ +│ My Recipes │ +├─────────────────────────────────────────────────────────┤ +│ Search: [___________] [Title/Tag Toggle] │ +│ │ +│ Display: [3] [6] [9] columns | Size: [====●==] │ +│ │ +│ Items: [12] [24] [48] [All] | Page: [◀ 1 of 5 ▶] │ +└─────────────────────────────────────────────────────────┘ +``` + +Sticky toolbar with three sections: +1. **Search Section**: Input with title/tag toggle, datalist for tag autocomplete +2. **Display Controls**: Column buttons + size slider with labels +3. **Pagination Section**: Items per page buttons + page navigation + +### 6. Dynamic Styling with CSS Variables +**File**: `packages/web/src/styles/RecipeList.css` (NEW) + +```css +.recipe-grid { + display: grid; + grid-template-columns: repeat(var(--column-count), 1fr); + gap: 1.5rem; +} + +.recipe-card img { + height: var(--recipe-image-height); + object-fit: cover; +} + +/* Responsive overrides */ +@media (max-width: 768px) { + .recipe-grid { + grid-template-columns: repeat(1, 1fr) !important; + } +} +``` + +Apply via inline styles: +```typescript +const gridStyle = { + '--column-count': columnCount, + '--recipe-image-height': `${SIZE_PRESETS[cardSize].imageHeight}px`, +}; +``` + +### 7. Search Debouncing +Implement 400ms debounce to prevent API spam: +```typescript +useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchInput); + }, 400); + return () => clearTimeout(timer); +}, [searchInput]); +``` + +### 8. Pagination Logic +- Reset to page 1 when search/filters change +- Handle "All" option with limit=10000 +- Update URL params on state changes +- Previous/Next buttons with disabled states +- Display "Page X of Y" info + +## Implementation Steps + +### Phase 1: Backend (Tag Search) +1. Modify `packages/api/src/routes/recipes.routes.ts` + - Add `tag` parameter extraction + - Add tag filtering to Prisma where clause +2. Test: `GET /api/recipes?tag=italian` + +### Phase 2: Frontend Foundation +1. Create `packages/web/src/styles/RecipeList.css` +2. Update `RecipeList.tsx`: + - Add all state variables + - Add localStorage load/save + - Add URL params sync +3. Update `packages/web/src/services/api.ts`: + - Add `tag?: string` to getAll params type + +### Phase 3: Search UI +1. Search input with debouncing +2. Title/Tag toggle buttons +3. Fetch and populate available tags +4. Datalist autocomplete for tags +5. Wire to API call + +### Phase 4: Display Controls +1. Column count buttons (3, 6, 9) +2. Size slider (0-6 range) with visual labels +3. CSS variables for dynamic styling +4. Wire to state with localStorage persistence + +### Phase 5: Pagination UI +1. Items per page selector (12, 24, 48, All) +2. Page navigation (Previous/Next buttons) +3. Page info display +4. Wire to API pagination +5. Reset page on filter changes + +### Phase 6: Integration & Polish +1. Combine all controls in sticky toolbar +2. Apply dynamic styles to grid +3. Responsive CSS media queries +4. Test all interactions +5. Fix UI/UX issues + +### Phase 7: Testing +1. Unit tests for RecipeList component +2. E2E tests for main flows +3. Manual testing on different screen sizes + +## Critical Files + +**Must Create:** +- `packages/web/src/styles/RecipeList.css` + +**Must Modify:** +- `packages/web/src/pages/RecipeList.tsx` (main implementation) +- `packages/api/src/routes/recipes.routes.ts` (tag search) +- `packages/web/src/services/api.ts` (TypeScript types) + +**Reference for Patterns:** +- `packages/web/src/pages/Cookbooks.tsx` (UI controls, state management) +- `packages/web/src/contexts/AuthContext.tsx` (localStorage patterns) + +## Verification Steps + +1. **Pagination**: Select "12 items per page", navigate to page 2, verify only 12 recipes shown +2. **Column Control**: Click "3 Columns", verify grid has 3 columns +3. **Size Slider**: Move slider to "XL", verify recipe cards and images increase in size +4. **Search by Title**: Type "pasta", verify filtered results (with debounce) +5. **Search by Tag**: Switch to "By Tag", type "italian", verify tagged recipes shown +6. **Persistence**: Refresh page, verify column count and size settings preserved +7. **URL Params**: Navigate to `/recipes?page=2&limit=24`, verify correct page loads +8. **Responsive**: Resize browser to mobile width, verify single column forced +9. **"All" Option**: Select "All", verify all recipes loaded +10. **Empty State**: Search for non-existent term, verify empty state displays + +## Technical Decisions + +1. **State Management**: React useState (no Redux needed) +2. **Backend Tag Search**: Extend API with `tag` parameter (preferred) +3. **URL Params**: Use for bookmarkable state +4. **Search Debounce**: 400ms delay +5. **"All" Pagination**: Send limit=10000 +6. **CSS Organization**: Separate RecipeList.css file +7. **Size Levels**: 7 presets (XS to XXL) +8. **Column/Size**: Independent controls diff --git a/e2e/meal-planner.spec.ts b/e2e/meal-planner.spec.ts new file mode 100644 index 0000000..9a3b90f --- /dev/null +++ b/e2e/meal-planner.spec.ts @@ -0,0 +1,390 @@ +import { test, expect, Page } from '@playwright/test'; + +// Helper function to login +async function login(page: Page) { + await page.goto('/login'); + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'TestPassword123!'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); +} + +// Helper function to create a test recipe +async function createTestRecipe(page: Page, title: string) { + await page.goto('/recipes/new'); + await page.fill('input[name="title"]', title); + await page.fill('textarea[name="description"]', `Delicious ${title}`); + + // Add ingredient + await page.click('button:has-text("Add Ingredient")'); + await page.fill('input[name="ingredients[0].name"]', 'Test Ingredient'); + await page.fill('input[name="ingredients[0].amount"]', '2'); + await page.fill('input[name="ingredients[0].unit"]', 'cups'); + + // Add instruction + await page.click('button:has-text("Add Step")'); + await page.fill('textarea[name="instructions[0].text"]', 'Mix ingredients'); + + // Set servings + await page.fill('input[name="servings"]', '4'); + + // Submit + await page.click('button[type="submit"]:has-text("Save Recipe")'); + await page.waitForURL(/\/recipes\/[a-z0-9]+/); +} + +test.describe('Meal Planner E2E Tests', () => { + test.beforeEach(async ({ page }) => { + // Create test user if needed and login + await page.goto('/register'); + const timestamp = Date.now(); + const email = `mealplanner-e2e-${timestamp}@example.com`; + + try { + await page.fill('input[name="email"]', email); + await page.fill('input[name="password"]', 'TestPassword123!'); + await page.fill('input[name="name"]', 'E2E Test User'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); + } catch (error) { + // User might already exist, try logging in + await login(page); + } + }); + + test('should display meal planner page', async ({ page }) => { + await page.goto('/meal-planner'); + + await expect(page.locator('h1:has-text("Meal Planner")')).toBeVisible(); + await expect(page.locator('button:has-text("Calendar")')).toBeVisible(); + await expect(page.locator('button:has-text("Weekly List")')).toBeVisible(); + }); + + test('should toggle between calendar and weekly views', async ({ page }) => { + await page.goto('/meal-planner'); + + // Should start in calendar view + await expect(page.locator('.calendar-view')).toBeVisible(); + + // Click Weekly List button + await page.click('button:has-text("Weekly List")'); + + // Should show weekly view + await expect(page.locator('.weekly-list-view')).toBeVisible(); + await expect(page.locator('.calendar-view')).not.toBeVisible(); + + // Click Calendar button + await page.click('button:has-text("Calendar")'); + + // Should show calendar view again + await expect(page.locator('.calendar-view')).toBeVisible(); + }); + + test('should navigate between months', async ({ page }) => { + await page.goto('/meal-planner'); + + // Get current month text + const currentMonthText = await page.locator('.date-range h2').textContent(); + + // Click Next button + await page.click('button:has-text("Next")'); + + // Month should have changed + const nextMonthText = await page.locator('.date-range h2').textContent(); + expect(nextMonthText).not.toBe(currentMonthText); + + // Click Previous button + await page.click('button:has-text("Previous")'); + + // Should be back to original month + const backToMonthText = await page.locator('.date-range h2').textContent(); + expect(backToMonthText).toBe(currentMonthText); + }); + + test('should navigate to today', async ({ page }) => { + await page.goto('/meal-planner'); + + // Navigate to next month + await page.click('button:has-text("Next")'); + + // Click Today button + await page.click('button:has-text("Today")'); + + // Should have a cell with "today" class + await expect(page.locator('.calendar-cell.today')).toBeVisible(); + }); + + test('should add meal to meal plan', async ({ page }) => { + // First, create a test recipe + await createTestRecipe(page, 'E2E Test Pancakes'); + + // Go to meal planner + await page.goto('/meal-planner'); + + // Click "Add Meal" button on a date + await page.click('.calendar-cell .btn-add-meal').first(); + + // Wait for modal to appear + await expect(page.locator('.add-meal-modal')).toBeVisible(); + + // Search for the recipe + await page.fill('input[placeholder*="Search"]', 'E2E Test Pancakes'); + + // Wait for recipe to appear and click it + await page.click('.recipe-item:has-text("E2E Test Pancakes")'); + + // Select meal type + await page.selectOption('select#mealType', 'BREAKFAST'); + + // Set servings + await page.fill('input#servings', '6'); + + // Add notes + await page.fill('textarea#notes', 'Extra syrup'); + + // Click Add Meal button + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Wait for modal to close + await expect(page.locator('.add-meal-modal')).not.toBeVisible(); + + // Verify meal appears in calendar + await expect(page.locator('.meal-card:has-text("E2E Test Pancakes")')).toBeVisible(); + await expect(page.locator('.meal-type-label:has-text("BREAKFAST")')).toBeVisible(); + }); + + test('should remove meal from meal plan', async ({ page }) => { + // First, add a meal (reusing the setup from previous test) + await createTestRecipe(page, 'E2E Test Sandwich'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await expect(page.locator('.add-meal-modal')).toBeVisible(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Sandwich'); + await page.click('.recipe-item:has-text("E2E Test Sandwich")'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + await expect(page.locator('.add-meal-modal')).not.toBeVisible(); + + // Verify meal is visible + await expect(page.locator('.meal-card:has-text("E2E Test Sandwich")')).toBeVisible(); + + // Click remove button + await page.click('.btn-remove-meal').first(); + + // Confirm the dialog + page.on('dialog', dialog => dialog.accept()); + + // Verify meal is removed + await expect(page.locator('.meal-card:has-text("E2E Test Sandwich")')).not.toBeVisible(); + }); + + test('should display meals in weekly list view', async ({ page }) => { + // Add a meal first + await createTestRecipe(page, 'E2E Test Salad'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await expect(page.locator('.add-meal-modal')).toBeVisible(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Salad'); + await page.click('.recipe-item:has-text("E2E Test Salad")'); + await page.selectOption('select#mealType', 'LUNCH'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Switch to weekly view + await page.click('button:has-text("Weekly List")'); + + // Verify meal appears in weekly view + await expect(page.locator('.weekly-list-view')).toBeVisible(); + await expect(page.locator('.meal-card:has-text("E2E Test Salad")')).toBeVisible(); + await expect(page.locator('h3:has-text("LUNCH")')).toBeVisible(); + }); + + test('should generate shopping list', async ({ page }) => { + // Add a meal with ingredients first + await createTestRecipe(page, 'E2E Test Soup'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await expect(page.locator('.add-meal-modal')).toBeVisible(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Soup'); + await page.click('.recipe-item:has-text("E2E Test Soup")'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + await expect(page.locator('.add-meal-modal')).not.toBeVisible(); + + // Click Generate Shopping List button + await page.click('button:has-text("Generate Shopping List")'); + + // Wait for shopping list modal + await expect(page.locator('.shopping-list-modal')).toBeVisible(); + + // Wait for list to generate + await expect(page.locator('.shopping-list-items')).toBeVisible(); + + // Verify ingredient appears + await expect(page.locator('.ingredient-name:has-text("Test Ingredient")')).toBeVisible(); + + // Verify amount + await expect(page.locator('.ingredient-amount:has-text("2 cups")')).toBeVisible(); + + // Verify recipe source + await expect(page.locator('.ingredient-recipes:has-text("E2E Test Soup")')).toBeVisible(); + }); + + test('should check off items in shopping list', async ({ page }) => { + // Setup: add a meal + await createTestRecipe(page, 'E2E Test Pasta'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Pasta'); + await page.click('.recipe-item:has-text("E2E Test Pasta")'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Open shopping list + await page.click('button:has-text("Generate Shopping List")'); + await expect(page.locator('.shopping-list-modal')).toBeVisible(); + + // Find and check a checkbox + const checkbox = page.locator('.shopping-list-item input[type="checkbox"]').first(); + await checkbox.check(); + await expect(checkbox).toBeChecked(); + + // Uncheck it + await checkbox.uncheck(); + await expect(checkbox).not.toBeChecked(); + }); + + test('should regenerate shopping list with custom date range', async ({ page }) => { + await page.goto('/meal-planner'); + + // Open shopping list + await page.click('button:has-text("Generate Shopping List")'); + await expect(page.locator('.shopping-list-modal')).toBeVisible(); + + // Change date range + const today = new Date(); + const nextWeek = new Date(today); + nextWeek.setDate(today.getDate() + 7); + + await page.fill('input#startDate', today.toISOString().split('T')[0]); + await page.fill('input#endDate', nextWeek.toISOString().split('T')[0]); + + // Click Regenerate button + await page.click('button:has-text("Regenerate")'); + + // Should show loading state briefly + await expect(page.locator('.loading:has-text("Generating")')).toBeVisible(); + + // Should complete + await expect(page.locator('.loading:has-text("Generating")')).not.toBeVisible({ timeout: 5000 }); + }); + + test('should copy shopping list to clipboard', async ({ page }) => { + // Setup: add a meal + await createTestRecipe(page, 'E2E Test Pizza'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Pizza'); + await page.click('.recipe-item:has-text("E2E Test Pizza")'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Open shopping list + await page.click('button:has-text("Generate Shopping List")'); + await expect(page.locator('.shopping-list-modal')).toBeVisible(); + + // Grant clipboard permissions + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + + // Mock the alert dialog + page.on('dialog', dialog => dialog.accept()); + + // Click Copy to Clipboard button + await page.click('button:has-text("Copy to Clipboard")'); + + // Verify clipboard content (this requires clipboard permissions) + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain('Test Ingredient'); + expect(clipboardText).toContain('2 cups'); + }); + + test('should display meal notes in meal plan', async ({ page }) => { + // Add a meal with notes + await createTestRecipe(page, 'E2E Test Steak'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Steak'); + await page.click('.recipe-item:has-text("E2E Test Steak")'); + await page.fill('textarea#notes', 'Cook medium rare'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Switch to weekly view to see full details + await page.click('button:has-text("Weekly List")'); + + // Verify notes appear + await expect(page.locator('.meal-notes:has-text("Cook medium rare")')).toBeVisible(); + }); + + test('should navigate to recipe from meal card', async ({ page }) => { + // Add a meal + await createTestRecipe(page, 'E2E Test Burrito'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Burrito'); + await page.click('.recipe-item:has-text("E2E Test Burrito")'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Click on the meal card + await page.click('.meal-card:has-text("E2E Test Burrito") .meal-card-content'); + + // Should navigate to recipe page + await page.waitForURL(/\/recipes\/[a-z0-9]+/); + await expect(page.locator('h1:has-text("E2E Test Burrito")')).toBeVisible(); + }); + + test('should close modals when clicking overlay', async ({ page }) => { + await page.goto('/meal-planner'); + + // Open add meal modal + await page.click('.calendar-cell .btn-add-meal').first(); + await expect(page.locator('.add-meal-modal')).toBeVisible(); + + // Click overlay (outside modal) + await page.click('.modal-overlay', { position: { x: 10, y: 10 } }); + + // Modal should close + await expect(page.locator('.add-meal-modal')).not.toBeVisible(); + + // Open shopping list modal + await page.click('button:has-text("Generate Shopping List")'); + await expect(page.locator('.shopping-list-modal')).toBeVisible(); + + // Click overlay + await page.click('.modal-overlay', { position: { x: 10, y: 10 } }); + + // Modal should close + await expect(page.locator('.shopping-list-modal')).not.toBeVisible(); + }); + + test('should persist meals after page reload', async ({ page }) => { + // Add a meal + await createTestRecipe(page, 'E2E Test Tacos'); + await page.goto('/meal-planner'); + + await page.click('.calendar-cell .btn-add-meal').first(); + await page.fill('input[placeholder*="Search"]', 'E2E Test Tacos'); + await page.click('.recipe-item:has-text("E2E Test Tacos")'); + await page.click('button[type="submit"]:has-text("Add Meal")'); + + // Verify meal is visible + await expect(page.locator('.meal-card:has-text("E2E Test Tacos")')).toBeVisible(); + + // Reload page + await page.reload(); + + // Meal should still be visible + await expect(page.locator('.meal-card:has-text("E2E Test Tacos")')).toBeVisible(); + }); +}); diff --git a/packages/api/src/routes/meal-plans.routes.real.test.ts b/packages/api/src/routes/meal-plans.routes.real.test.ts new file mode 100644 index 0000000..25b9bb5 --- /dev/null +++ b/packages/api/src/routes/meal-plans.routes.real.test.ts @@ -0,0 +1,631 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../index'; +import prisma from '../config/database'; + +describe('Meal Plans Routes - Real Integration Tests', () => { + let authToken: string; + let testUserId: string; + let testRecipeId: string; + + beforeAll(async () => { + // Create test user and get auth token + const userResponse = await request(app) + .post('/api/auth/register') + .send({ + email: `mealplan-test-${Date.now()}@example.com`, + password: 'TestPassword123!', + name: 'Meal Plan Test User', + }); + + testUserId = userResponse.body.data.user.id; + authToken = userResponse.body.data.accessToken; + + // Create test recipe + const recipeResponse = await request(app) + .post('/api/recipes') + .set('Authorization', `Bearer ${authToken}`) + .send({ + title: 'Test Recipe for Meal Plans', + description: 'A test recipe', + servings: 4, + ingredients: [ + { name: 'Flour', amount: '2', unit: 'cups', order: 0 }, + { name: 'Sugar', amount: '1', unit: 'cup', order: 1 }, + { name: 'Eggs', amount: '3', unit: '', order: 2 }, + ], + instructions: [ + { step: 1, text: 'Mix dry ingredients' }, + { step: 2, text: 'Add eggs and mix well' }, + ], + }); + + testRecipeId = recipeResponse.body.data.id; + }); + + afterAll(async () => { + // Cleanup in order: meal plans (cascade deletes meals), recipes, user + await prisma.mealPlan.deleteMany({ where: { userId: testUserId } }); + + // Delete recipe and its relations + await prisma.ingredient.deleteMany({ where: { recipeId: testRecipeId } }); + await prisma.instruction.deleteMany({ where: { recipeId: testRecipeId } }); + await prisma.recipe.delete({ where: { id: testRecipeId } }); + + // Delete user + await prisma.user.delete({ where: { id: testUserId } }); + }); + + beforeEach(async () => { + // Clean meal plans before each test + await prisma.mealPlan.deleteMany({ where: { userId: testUserId } }); + }); + + describe('Full CRUD Flow', () => { + it('should create, read, update, and delete meal plan', async () => { + // CREATE + const createResponse = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-01-15', + notes: 'Test meal plan', + }) + .expect(201); + + const mealPlanId = createResponse.body.data.id; + expect(createResponse.body.data.notes).toBe('Test meal plan'); + expect(createResponse.body.data.meals).toEqual([]); + + // READ by ID + const getResponse = await request(app) + .get(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(getResponse.body.data.id).toBe(mealPlanId); + expect(getResponse.body.data.notes).toBe('Test meal plan'); + + // READ by date + const getByDateResponse = await request(app) + .get('/api/meal-plans/date/2025-01-15') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(getByDateResponse.body.data.id).toBe(mealPlanId); + + // READ list + const listResponse = await request(app) + .get('/api/meal-plans?startDate=2025-01-01&endDate=2025-01-31') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(listResponse.body.data).toHaveLength(1); + expect(listResponse.body.data[0].id).toBe(mealPlanId); + + // UPDATE + const updateResponse = await request(app) + .put(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ notes: 'Updated notes' }) + .expect(200); + + expect(updateResponse.body.data.notes).toBe('Updated notes'); + + // DELETE + await request(app) + .delete(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + // Verify deletion + await request(app) + .get(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(404); + }); + + it('should create meal plan with meals', async () => { + const createResponse = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-01-16', + notes: 'Meal plan with meals', + meals: [ + { + mealType: 'BREAKFAST', + recipeId: testRecipeId, + servings: 4, + notes: 'Morning meal', + }, + { + mealType: 'LUNCH', + recipeId: testRecipeId, + servings: 6, + }, + ], + }) + .expect(201); + + expect(createResponse.body.data.meals).toHaveLength(2); + expect(createResponse.body.data.meals[0].mealType).toBe('BREAKFAST'); + expect(createResponse.body.data.meals[0].servings).toBe(4); + expect(createResponse.body.data.meals[1].mealType).toBe('LUNCH'); + expect(createResponse.body.data.meals[1].servings).toBe(6); + }); + }); + + describe('Meal Management', () => { + let mealPlanId: string; + + beforeEach(async () => { + // Create a meal plan for each test + const response = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-01-20', + notes: 'Test plan for meals', + }); + + mealPlanId = response.body.data.id; + }); + + it('should add meal to meal plan', async () => { + const addMealResponse = await request(app) + .post(`/api/meal-plans/${mealPlanId}/meals`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + mealType: 'DINNER', + recipeId: testRecipeId, + servings: 4, + notes: 'Dinner notes', + }) + .expect(201); + + expect(addMealResponse.body.data.mealType).toBe('DINNER'); + expect(addMealResponse.body.data.servings).toBe(4); + expect(addMealResponse.body.data.notes).toBe('Dinner notes'); + + // Verify meal was added + const getResponse = await request(app) + .get(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(getResponse.body.data.meals).toHaveLength(1); + }); + + it('should update meal', async () => { + // Add a meal first + const addResponse = await request(app) + .post(`/api/meal-plans/${mealPlanId}/meals`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + mealType: 'BREAKFAST', + recipeId: testRecipeId, + servings: 4, + }); + + const mealId = addResponse.body.data.id; + + // Update the meal + const updateResponse = await request(app) + .put(`/api/meal-plans/meals/${mealId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + servings: 8, + notes: 'Updated meal notes', + mealType: 'BRUNCH', + }) + .expect(200); + + expect(updateResponse.body.data.servings).toBe(8); + expect(updateResponse.body.data.notes).toBe('Updated meal notes'); + }); + + it('should delete meal', async () => { + // Add a meal first + const addResponse = await request(app) + .post(`/api/meal-plans/${mealPlanId}/meals`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + mealType: 'LUNCH', + recipeId: testRecipeId, + servings: 4, + }); + + const mealId = addResponse.body.data.id; + + // Delete the meal + await request(app) + .delete(`/api/meal-plans/meals/${mealId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + // Verify meal was deleted + const getResponse = await request(app) + .get(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(getResponse.body.data.meals).toHaveLength(0); + }); + + it('should auto-increment order for meals of same type', async () => { + // Add first BREAKFAST meal + const meal1Response = await request(app) + .post(`/api/meal-plans/${mealPlanId}/meals`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + mealType: 'BREAKFAST', + recipeId: testRecipeId, + servings: 4, + }); + + // Add second BREAKFAST meal + const meal2Response = await request(app) + .post(`/api/meal-plans/${mealPlanId}/meals`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + mealType: 'BREAKFAST', + recipeId: testRecipeId, + servings: 2, + }); + + expect(meal1Response.body.data.order).toBe(0); + expect(meal2Response.body.data.order).toBe(1); + }); + }); + + describe('Shopping List Generation', () => { + it('should generate shopping list correctly', async () => { + // Create meal plan with meals + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-02-01', + meals: [ + { mealType: 'BREAKFAST', recipeId: testRecipeId, servings: 4 }, + ], + }); + + // Generate shopping list + const response = await request(app) + .post('/api/meal-plans/shopping-list') + .set('Authorization', `Bearer ${authToken}`) + .send({ + startDate: '2025-02-01', + endDate: '2025-02-28', + }) + .expect(200); + + expect(response.body.data.items).toHaveLength(3); // Flour, Sugar, Eggs + expect(response.body.data.dateRange.start).toBe('2025-02-01'); + expect(response.body.data.dateRange.end).toBe('2025-02-28'); + expect(response.body.data.recipeCount).toBe(1); + + // Verify ingredients + const flourItem = response.body.data.items.find((item: any) => item.ingredientName === 'Flour'); + expect(flourItem).toBeDefined(); + expect(flourItem.totalAmount).toBe(2); + expect(flourItem.unit).toBe('cups'); + expect(flourItem.recipes).toContain('Test Recipe for Meal Plans'); + }); + + it('should aggregate ingredients from multiple meals', async () => { + // Create meal plans with same recipe + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-03-01', + meals: [ + { mealType: 'BREAKFAST', recipeId: testRecipeId, servings: 4 }, + ], + }); + + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-03-02', + meals: [ + { mealType: 'DINNER', recipeId: testRecipeId, servings: 4 }, + ], + }); + + // Generate shopping list + const response = await request(app) + .post('/api/meal-plans/shopping-list') + .set('Authorization', `Bearer ${authToken}`) + .send({ + startDate: '2025-03-01', + endDate: '2025-03-31', + }) + .expect(200); + + // Flour should be doubled (2 cups per recipe * 2 recipes = 4 cups) + const flourItem = response.body.data.items.find((item: any) => item.ingredientName === 'Flour'); + expect(flourItem.totalAmount).toBe(4); + expect(response.body.data.recipeCount).toBe(2); + }); + + it('should apply servings multiplier', async () => { + // Create meal plan with doubled servings + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-04-01', + meals: [ + { mealType: 'DINNER', recipeId: testRecipeId, servings: 8 }, // double the recipe servings (4 -> 8) + ], + }); + + // Generate shopping list + const response = await request(app) + .post('/api/meal-plans/shopping-list') + .set('Authorization', `Bearer ${authToken}`) + .send({ + startDate: '2025-04-01', + endDate: '2025-04-30', + }) + .expect(200); + + // Flour should be doubled (2 cups * 2 = 4 cups) + const flourItem = response.body.data.items.find((item: any) => item.ingredientName === 'Flour'); + expect(flourItem.totalAmount).toBe(4); + + // Sugar should be doubled (1 cup * 2 = 2 cups) + const sugarItem = response.body.data.items.find((item: any) => item.ingredientName === 'Sugar'); + expect(sugarItem.totalAmount).toBe(2); + }); + + it('should return empty list for date range with no meals', async () => { + const response = await request(app) + .post('/api/meal-plans/shopping-list') + .set('Authorization', `Bearer ${authToken}`) + .send({ + startDate: '2025-12-01', + endDate: '2025-12-31', + }) + .expect(200); + + expect(response.body.data.items).toHaveLength(0); + expect(response.body.data.recipeCount).toBe(0); + }); + }); + + describe('Upsert Behavior', () => { + it('should update existing meal plan when creating with same date', async () => { + // Create initial meal plan + const createResponse = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-05-01', + notes: 'Initial notes', + }) + .expect(201); + + const firstId = createResponse.body.data.id; + + // Create again with same date (should upsert) + const upsertResponse = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-05-01', + notes: 'Updated notes', + }) + .expect(201); + + const secondId = upsertResponse.body.data.id; + + // IDs should be the same (upserted, not created new) + expect(firstId).toBe(secondId); + expect(upsertResponse.body.data.notes).toBe('Updated notes'); + + // Verify only one meal plan exists for this date + const listResponse = await request(app) + .get('/api/meal-plans?startDate=2025-05-01&endDate=2025-05-01') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(listResponse.body.data).toHaveLength(1); + }); + }); + + describe('Cascade Deletes', () => { + it('should cascade delete meals when deleting meal plan', async () => { + // Create meal plan with meals + const createResponse = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-06-01', + notes: 'Test cascade', + meals: [ + { mealType: 'BREAKFAST', recipeId: testRecipeId, servings: 4 }, + { mealType: 'LUNCH', recipeId: testRecipeId, servings: 4 }, + ], + }); + + const mealPlanId = createResponse.body.data.id; + const mealIds = createResponse.body.data.meals.map((m: any) => m.id); + + // Delete meal plan + await request(app) + .delete(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + // Verify meals were also deleted + for (const mealId of mealIds) { + const mealCount = await prisma.meal.count({ + where: { id: mealId }, + }); + expect(mealCount).toBe(0); + } + }); + }); + + describe('Authorization', () => { + let otherUserToken: string; + let otherUserId: string; + let mealPlanId: string; + + beforeAll(async () => { + // Create another user + const userResponse = await request(app) + .post('/api/auth/register') + .send({ + email: `other-user-${Date.now()}@example.com`, + password: 'OtherPassword123!', + name: 'Other User', + }); + + otherUserId = userResponse.body.data.user.id; + otherUserToken = userResponse.body.data.accessToken; + }); + + afterAll(async () => { + // Cleanup other user + await prisma.mealPlan.deleteMany({ where: { userId: otherUserId } }); + await prisma.user.delete({ where: { id: otherUserId } }); + }); + + beforeEach(async () => { + // Create meal plan for main user + const response = await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date: '2025-07-01', + notes: 'User 1 plan', + }); + + mealPlanId = response.body.data.id; + }); + + it('should not allow user to read another users meal plan', async () => { + await request(app) + .get(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${otherUserToken}`) + .expect(403); + }); + + it('should not allow user to update another users meal plan', async () => { + await request(app) + .put(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${otherUserToken}`) + .send({ notes: 'Hacked notes' }) + .expect(403); + }); + + it('should not allow user to delete another users meal plan', async () => { + await request(app) + .delete(`/api/meal-plans/${mealPlanId}`) + .set('Authorization', `Bearer ${otherUserToken}`) + .expect(403); + }); + + it('should not allow user to add meal to another users meal plan', async () => { + await request(app) + .post(`/api/meal-plans/${mealPlanId}/meals`) + .set('Authorization', `Bearer ${otherUserToken}`) + .send({ + mealType: 'DINNER', + recipeId: testRecipeId, + }) + .expect(403); + }); + + it('should not include other users meal plans in list', async () => { + // Create meal plan for other user + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${otherUserToken}`) + .send({ + date: '2025-07-01', + notes: 'User 2 plan', + }); + + // Get list for main user + const response = await request(app) + .get('/api/meal-plans?startDate=2025-07-01&endDate=2025-07-31') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + // Should only see own meal plan + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe(mealPlanId); + }); + + it('should not include other users meals in shopping list', async () => { + // Create meal plan for other user with same recipe + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${otherUserToken}`) + .send({ + date: '2025-07-02', + meals: [ + { mealType: 'BREAKFAST', recipeId: testRecipeId, servings: 4 }, + ], + }); + + // Generate shopping list for main user (who has no meals) + const response = await request(app) + .post('/api/meal-plans/shopping-list') + .set('Authorization', `Bearer ${authToken}`) + .send({ + startDate: '2025-07-01', + endDate: '2025-07-31', + }) + .expect(200); + + // Should be empty (other user's meals not included) + expect(response.body.data.items).toHaveLength(0); + expect(response.body.data.recipeCount).toBe(0); + }); + }); + + describe('Date Range Queries', () => { + beforeEach(async () => { + // Create meal plans for multiple dates + const dates = ['2025-08-01', '2025-08-15', '2025-08-31', '2025-09-01']; + + for (const date of dates) { + await request(app) + .post('/api/meal-plans') + .set('Authorization', `Bearer ${authToken}`) + .send({ + date, + notes: `Plan for ${date}`, + }); + } + }); + + it('should return only meal plans within date range', async () => { + const response = await request(app) + .get('/api/meal-plans?startDate=2025-08-01&endDate=2025-08-31') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.data).toHaveLength(3); // Aug 1, 15, 31 (not Sep 1) + }); + + it('should return meal plans in chronological order', async () => { + const response = await request(app) + .get('/api/meal-plans?startDate=2025-08-01&endDate=2025-09-30') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + const dates = response.body.data.map((mp: any) => mp.date.split('T')[0]); + expect(dates).toEqual(['2025-08-01', '2025-08-15', '2025-08-31', '2025-09-01']); + }); + }); +}); diff --git a/packages/api/src/routes/meal-plans.routes.test.ts b/packages/api/src/routes/meal-plans.routes.test.ts new file mode 100644 index 0000000..3c0b4ab --- /dev/null +++ b/packages/api/src/routes/meal-plans.routes.test.ts @@ -0,0 +1,1024 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import mealPlansRouter from './meal-plans.routes'; + +// Mock Prisma +vi.mock('../config/database', () => ({ + default: { + mealPlan: { + findMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + upsert: vi.fn(), + }, + meal: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + }, + mealRecipe: { + create: vi.fn(), + }, + recipe: { + findUnique: vi.fn(), + }, + }, +})); + +// Mock auth middleware +vi.mock('../middleware/auth.middleware', () => ({ + requireAuth: (req: any, res: any, next: any) => { + req.user = { id: 'test-user-id', email: 'test@example.com', role: 'USER' }; + next(); + }, +})); + +describe('Meal Plans Routes - Unit Tests', () => { + let app: express.Application; + + beforeEach(() => { + vi.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/meal-plans', mealPlansRouter); + }); + + describe('GET /meal-plans', () => { + it('should return meal plans within date range', async () => { + const mockMealPlans = [ + { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockResolvedValue(mockMealPlans as any); + + const response = await request(app) + .get('/meal-plans?startDate=2025-01-01&endDate=2025-01-31') + .expect(200); + + expect(response.body.data).toHaveLength(1); + expect(prisma.default.mealPlan.findMany).toHaveBeenCalledWith({ + where: { + userId: 'test-user-id', + date: { + gte: expect.any(Date), + lte: expect.any(Date), + }, + }, + include: expect.any(Object), + orderBy: { date: 'asc' }, + }); + }); + + it('should return 400 if startDate is missing', async () => { + const response = await request(app) + .get('/meal-plans?endDate=2025-01-31') + .expect(400); + + expect(response.body.error).toBe('startDate and endDate are required'); + }); + + it('should return 400 if endDate is missing', async () => { + const response = await request(app) + .get('/meal-plans?startDate=2025-01-01') + .expect(400); + + expect(response.body.error).toBe('startDate and endDate are required'); + }); + + it('should return empty array if no meal plans found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockResolvedValue([]); + + const response = await request(app) + .get('/meal-plans?startDate=2025-01-01&endDate=2025-01-31') + .expect(200); + + expect(response.body.data).toHaveLength(0); + }); + + it('should handle database errors gracefully', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .get('/meal-plans?startDate=2025-01-01&endDate=2025-01-31') + .expect(500); + + expect(response.body.error).toBe('Failed to fetch meal plans'); + }); + }); + + describe('GET /meal-plans/date/:date', () => { + it('should return meal plan for specific date', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findUnique).mockResolvedValue(mockMealPlan as any); + + const response = await request(app) + .get('/meal-plans/date/2025-01-15') + .expect(200); + + expect(response.body.data.id).toBe('mp1'); + expect(prisma.default.mealPlan.findUnique).toHaveBeenCalledWith({ + where: { + userId_date: { + userId: 'test-user-id', + date: expect.any(Date), + }, + }, + include: expect.any(Object), + }); + }); + + it('should return null data if no plan exists for date', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findUnique).mockResolvedValue(null); + + const response = await request(app) + .get('/meal-plans/date/2025-01-15') + .expect(200); + + expect(response.body.data).toBeNull(); + }); + + it('should handle database errors', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findUnique).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .get('/meal-plans/date/2025-01-15') + .expect(500); + + expect(response.body.error).toBe('Failed to fetch meal plan'); + }); + }); + + describe('GET /meal-plans/:id', () => { + it('should return meal plan by ID', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockMealPlan as any); + + const response = await request(app) + .get('/meal-plans/mp1') + .expect(200); + + expect(response.body.data.id).toBe('mp1'); + }); + + it('should return 404 if meal plan not found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + const response = await request(app) + .get('/meal-plans/nonexistent') + .expect(404); + + expect(response.body.error).toBe('Meal plan not found'); + }); + + it('should filter by userId automatically', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + await request(app) + .get('/meal-plans/mp1') + .expect(404); + + expect(prisma.default.mealPlan.findFirst).toHaveBeenCalledWith({ + where: { + id: 'mp1', + userId: 'test-user-id', + }, + include: expect.any(Object), + }); + }); + + it('should handle database errors', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .get('/meal-plans/mp1') + .expect(500); + + expect(response.body.error).toBe('Failed to fetch meal plan'); + }); + }); + + describe('POST /meal-plans', () => { + it('should create new meal plan', async () => { + const mockCreatedPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'New plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.upsert).mockResolvedValue(mockCreatedPlan as any); + vi.mocked(prisma.default.mealPlan.findUnique).mockResolvedValue(mockCreatedPlan as any); + + const response = await request(app) + .post('/meal-plans') + .send({ + date: '2025-01-15', + notes: 'New plan', + }) + .expect(201); + + expect(response.body.data.id).toBe('mp1'); + expect(response.body.data.notes).toBe('New plan'); + }); + + it('should create meal plan with meals', async () => { + const mockCreatedPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'New plan', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCreatedMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCompletePlan = { + ...mockCreatedPlan, + meals: [ + { + ...mockCreatedMeal, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { id: 'r1', title: 'Pancakes' }, + }, + }, + ], + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.upsert).mockResolvedValue(mockCreatedPlan as any); + vi.mocked(prisma.default.meal.deleteMany).mockResolvedValue({ count: 0 } as any); + vi.mocked(prisma.default.meal.create).mockResolvedValue(mockCreatedMeal as any); + vi.mocked(prisma.default.mealRecipe.create).mockResolvedValue({ mealId: 'm1', recipeId: 'r1' } as any); + vi.mocked(prisma.default.mealPlan.findUnique).mockResolvedValue(mockCompletePlan as any); + + const response = await request(app) + .post('/meal-plans') + .send({ + date: '2025-01-15', + notes: 'New plan', + meals: [ + { + mealType: 'BREAKFAST', + recipeId: 'r1', + servings: 4, + }, + ], + }) + .expect(201); + + expect(response.body.data.meals).toHaveLength(1); + }); + + it('should return 400 if date is missing', async () => { + const response = await request(app) + .post('/meal-plans') + .send({ + notes: 'New plan', + }) + .expect(400); + + expect(response.body.error).toBe('Date is required'); + }); + + it('should handle database errors', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.upsert).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .post('/meal-plans') + .send({ + date: '2025-01-15', + notes: 'New plan', + }) + .expect(500); + + expect(response.body.error).toBe('Failed to create meal plan'); + }); + }); + + describe('PUT /meal-plans/:id', () => { + it('should update meal plan notes', async () => { + const mockExistingPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Old notes', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUpdatedPlan = { + ...mockExistingPlan, + notes: 'Updated notes', + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockExistingPlan as any); + vi.mocked(prisma.default.mealPlan.update).mockResolvedValue(mockUpdatedPlan as any); + + const response = await request(app) + .put('/meal-plans/mp1') + .send({ + notes: 'Updated notes', + }) + .expect(200); + + expect(response.body.data.notes).toBe('Updated notes'); + }); + + it('should return 404 if meal plan not found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + const response = await request(app) + .put('/meal-plans/nonexistent') + .send({ + notes: 'Updated notes', + }) + .expect(404); + + expect(response.body.error).toBe('Meal plan not found'); + }); + + it('should verify user ownership', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + await request(app) + .put('/meal-plans/mp1') + .send({ + notes: 'Updated notes', + }) + .expect(404); + + expect(prisma.default.mealPlan.findFirst).toHaveBeenCalledWith({ + where: { id: 'mp1', userId: 'test-user-id' }, + }); + }); + + it('should handle database errors', async () => { + const mockExistingPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Old notes', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockExistingPlan as any); + vi.mocked(prisma.default.mealPlan.update).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .put('/meal-plans/mp1') + .send({ + notes: 'Updated notes', + }) + .expect(500); + + expect(response.body.error).toBe('Failed to update meal plan'); + }); + }); + + describe('DELETE /meal-plans/:id', () => { + it('should delete meal plan', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockMealPlan as any); + vi.mocked(prisma.default.mealPlan.delete).mockResolvedValue(mockMealPlan as any); + + const response = await request(app) + .delete('/meal-plans/mp1') + .expect(200); + + expect(response.body.message).toBe('Meal plan deleted successfully'); + }); + + it('should return 404 if meal plan not found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + const response = await request(app) + .delete('/meal-plans/nonexistent') + .expect(404); + + expect(response.body.error).toBe('Meal plan not found'); + }); + + it('should verify user ownership', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + await request(app) + .delete('/meal-plans/mp1') + .expect(404); + + expect(prisma.default.mealPlan.findFirst).toHaveBeenCalledWith({ + where: { id: 'mp1', userId: 'test-user-id' }, + }); + }); + + it('should handle database errors', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockMealPlan as any); + vi.mocked(prisma.default.mealPlan.delete).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .delete('/meal-plans/mp1') + .expect(500); + + expect(response.body.error).toBe('Failed to delete meal plan'); + }); + }); + + describe('POST /meal-plans/:id/meals', () => { + it('should add meal to existing plan', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCreatedMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCompleteMeal = { + ...mockCreatedMeal, + recipe: { + mealId: 'm1', + recipeId: 'r1', + recipe: { id: 'r1', title: 'Pancakes' }, + }, + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockMealPlan as any); + vi.mocked(prisma.default.meal.create).mockResolvedValue(mockCreatedMeal as any); + vi.mocked(prisma.default.mealRecipe.create).mockResolvedValue({ mealId: 'm1', recipeId: 'r1' } as any); + vi.mocked(prisma.default.meal.findUnique).mockResolvedValue(mockCompleteMeal as any); + + const response = await request(app) + .post('/meal-plans/mp1/meals') + .send({ + mealType: 'BREAKFAST', + recipeId: 'r1', + servings: 4, + }) + .expect(201); + + expect(response.body.data.id).toBe('m1'); + }); + + it('should return 404 if meal plan not found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + const response = await request(app) + .post('/meal-plans/nonexistent/meals') + .send({ + mealType: 'BREAKFAST', + recipeId: 'r1', + servings: 4, + }) + .expect(404); + + expect(response.body.error).toBe('Meal plan not found'); + }); + + it('should verify user ownership', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(null); + + await request(app) + .post('/meal-plans/mp1/meals') + .send({ + mealType: 'BREAKFAST', + recipeId: 'r1', + }) + .expect(404); + + expect(prisma.default.mealPlan.findFirst).toHaveBeenCalledWith({ + where: { id: 'mp1', userId: 'test-user-id' }, + include: { meals: true }, + }); + }); + + it('should return 400 if required fields are missing', async () => { + const response = await request(app) + .post('/meal-plans/mp1/meals') + .send({ + mealType: 'BREAKFAST', + // missing recipeId + }) + .expect(400); + + expect(response.body.error).toBe('mealType and recipeId are required'); + }); + + it('should calculate order based on existing meals of same type', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [ + { + id: 'm0', + mealType: 'BREAKFAST', + order: 0, + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCreatedMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 1, + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockMealPlan as any); + vi.mocked(prisma.default.meal.create).mockResolvedValue(mockCreatedMeal as any); + vi.mocked(prisma.default.mealRecipe.create).mockResolvedValue({ mealId: 'm1', recipeId: 'r1' } as any); + vi.mocked(prisma.default.meal.findUnique).mockResolvedValue(mockCreatedMeal as any); + + await request(app) + .post('/meal-plans/mp1/meals') + .send({ + mealType: 'BREAKFAST', + recipeId: 'r1', + servings: 4, + }) + .expect(201); + + expect(prisma.default.meal.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + order: 1, // Should be 1 since there's already one BREAKFAST meal + }), + }); + }); + + it('should handle database errors', async () => { + const mockMealPlan = { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + notes: 'Test plan', + meals: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findFirst).mockResolvedValue(mockMealPlan as any); + vi.mocked(prisma.default.meal.create).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .post('/meal-plans/mp1/meals') + .send({ + mealType: 'BREAKFAST', + recipeId: 'r1', + servings: 4, + }) + .expect(500); + + expect(response.body.error).toBe('Failed to add meal'); + }); + }); + + describe('PUT /meal-plans/meals/:mealId', () => { + it('should update meal properties', async () => { + const mockMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + notes: 'Old notes', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUpdatedMeal = { + ...mockMeal, + servings: 6, + notes: 'Updated notes', + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(mockMeal as any); + vi.mocked(prisma.default.meal.update).mockResolvedValue(mockUpdatedMeal as any); + + const response = await request(app) + .put('/meal-plans/meals/m1') + .send({ + servings: 6, + notes: 'Updated notes', + }) + .expect(200); + + expect(response.body.data.servings).toBe(6); + expect(response.body.data.notes).toBe('Updated notes'); + }); + + it('should return 404 if meal not found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(null); + + const response = await request(app) + .put('/meal-plans/meals/nonexistent') + .send({ + servings: 6, + }) + .expect(404); + + expect(response.body.error).toBe('Meal not found'); + }); + + it('should verify ownership through meal plan', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(null); + + await request(app) + .put('/meal-plans/meals/m1') + .send({ + servings: 6, + }) + .expect(404); + + expect(prisma.default.meal.findFirst).toHaveBeenCalledWith({ + where: { + id: 'm1', + mealPlan: { userId: 'test-user-id' }, + }, + }); + }); + + it('should handle database errors', async () => { + const mockMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(mockMeal as any); + vi.mocked(prisma.default.meal.update).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .put('/meal-plans/meals/m1') + .send({ + servings: 6, + }) + .expect(500); + + expect(response.body.error).toBe('Failed to update meal'); + }); + }); + + describe('DELETE /meal-plans/meals/:mealId', () => { + it('should delete meal', async () => { + const mockMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(mockMeal as any); + vi.mocked(prisma.default.meal.delete).mockResolvedValue(mockMeal as any); + + const response = await request(app) + .delete('/meal-plans/meals/m1') + .expect(200); + + expect(response.body.message).toBe('Meal removed successfully'); + }); + + it('should return 404 if meal not found', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(null); + + const response = await request(app) + .delete('/meal-plans/meals/nonexistent') + .expect(404); + + expect(response.body.error).toBe('Meal not found'); + }); + + it('should verify ownership through meal plan', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(null); + + await request(app) + .delete('/meal-plans/meals/m1') + .expect(404); + + expect(prisma.default.meal.findFirst).toHaveBeenCalledWith({ + where: { + id: 'm1', + mealPlan: { userId: 'test-user-id' }, + }, + }); + }); + + it('should handle database errors', async () => { + const mockMeal = { + id: 'm1', + mealPlanId: 'mp1', + mealType: 'BREAKFAST', + order: 0, + servings: 4, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.meal.findFirst).mockResolvedValue(mockMeal as any); + vi.mocked(prisma.default.meal.delete).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .delete('/meal-plans/meals/m1') + .expect(500); + + expect(response.body.error).toBe('Failed to remove meal'); + }); + }); + + describe('POST /meal-plans/shopping-list', () => { + it('should generate shopping list with aggregated ingredients', async () => { + const mockMealPlans = [ + { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + meals: [ + { + id: 'm1', + servings: 4, + recipe: { + recipe: { + id: 'r1', + title: 'Pancakes', + servings: 4, + ingredients: [ + { + name: 'Flour', + amount: '2', + unit: 'cups', + }, + { + name: 'Eggs', + amount: '3', + unit: '', + }, + ], + sections: [], + }, + }, + }, + ], + }, + ]; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockResolvedValue(mockMealPlans as any); + + const response = await request(app) + .post('/meal-plans/shopping-list') + .send({ + startDate: '2025-01-01', + endDate: '2025-01-31', + }) + .expect(200); + + expect(response.body.data.items).toHaveLength(2); + expect(response.body.data.items[0].ingredientName).toBe('flour'); + expect(response.body.data.items[0].totalAmount).toBe(2); + expect(response.body.data.items[0].unit).toBe('cups'); + expect(response.body.data.items[0].recipes).toContain('Pancakes'); + }); + + it('should apply servings multiplier', async () => { + const mockMealPlans = [ + { + id: 'mp1', + userId: 'test-user-id', + date: new Date('2025-01-15'), + meals: [ + { + id: 'm1', + servings: 8, // double the recipe servings + recipe: { + recipe: { + id: 'r1', + title: 'Pancakes', + servings: 4, + ingredients: [ + { + name: 'Flour', + amount: '2', + unit: 'cups', + }, + ], + sections: [], + }, + }, + }, + ], + }, + ]; + + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockResolvedValue(mockMealPlans as any); + + const response = await request(app) + .post('/meal-plans/shopping-list') + .send({ + startDate: '2025-01-01', + endDate: '2025-01-31', + }) + .expect(200); + + expect(response.body.data.items[0].totalAmount).toBe(4); // 2 * (8/4) = 4 + }); + + it('should return 400 if startDate is missing', async () => { + const response = await request(app) + .post('/meal-plans/shopping-list') + .send({ + endDate: '2025-01-31', + }) + .expect(400); + + expect(response.body.error).toBe('startDate and endDate are required'); + }); + + it('should return 400 if endDate is missing', async () => { + const response = await request(app) + .post('/meal-plans/shopping-list') + .send({ + startDate: '2025-01-01', + }) + .expect(400); + + expect(response.body.error).toBe('startDate and endDate are required'); + }); + + it('should handle empty date range', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockResolvedValue([]); + + const response = await request(app) + .post('/meal-plans/shopping-list') + .send({ + startDate: '2025-01-01', + endDate: '2025-01-31', + }) + .expect(200); + + expect(response.body.data.items).toHaveLength(0); + expect(response.body.data.recipeCount).toBe(0); + }); + + it('should handle database errors', async () => { + const prisma = await import('../config/database'); + vi.mocked(prisma.default.mealPlan.findMany).mockRejectedValue( + new Error('Database error') + ); + + const response = await request(app) + .post('/meal-plans/shopping-list') + .send({ + startDate: '2025-01-01', + endDate: '2025-01-31', + }) + .expect(500); + + expect(response.body.error).toBe('Failed to generate shopping list'); + }); + }); +}); diff --git a/packages/api/src/routes/meal-plans.routes.ts b/packages/api/src/routes/meal-plans.routes.ts new file mode 100644 index 0000000..f82f909 --- /dev/null +++ b/packages/api/src/routes/meal-plans.routes.ts @@ -0,0 +1,595 @@ +import { Router, Request, Response } from 'express'; +import prisma from '../config/database'; +import { requireAuth } from '../middleware/auth.middleware'; +import { MealType } from '@basil/shared'; + +const router = Router(); + +// Apply auth to all routes +router.use(requireAuth); + +// Get meal plans for date range +router.get('/', async (req: Request, res: Response) => { + try { + const { startDate, endDate } = req.query; + const userId = req.user!.id; + + if (!startDate || !endDate) { + return res.status(400).json({ + error: 'startDate and endDate are required' + }); + } + + const mealPlans = await prisma.mealPlan.findMany({ + where: { + userId, + date: { + gte: new Date(startDate as string), + lte: new Date(endDate as string), + }, + }, + include: { + meals: { + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + orderBy: [ + { mealType: 'asc' }, + { order: 'asc' }, + ], + }, + }, + orderBy: { date: 'asc' }, + }); + + res.json({ data: mealPlans }); + } catch (error) { + console.error('Error fetching meal plans:', error); + res.status(500).json({ error: 'Failed to fetch meal plans' }); + } +}); + +// Get meal plan by date +router.get('/date/:date', async (req: Request, res: Response) => { + try { + const { date } = req.params; + const userId = req.user!.id; + + const mealPlan = await prisma.mealPlan.findUnique({ + where: { + userId_date: { + userId, + date: new Date(date), + }, + }, + include: { + meals: { + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + orderBy: [ + { mealType: 'asc' }, + { order: 'asc' }, + ], + }, + }, + }); + + res.json({ data: mealPlan }); + } catch (error) { + console.error('Error fetching meal plan:', error); + res.status(500).json({ error: 'Failed to fetch meal plan' }); + } +}); + +// Get single meal plan by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = req.user!.id; + + const mealPlan = await prisma.mealPlan.findFirst({ + where: { + id, + userId, + }, + include: { + meals: { + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + orderBy: [ + { mealType: 'asc' }, + { order: 'asc' }, + ], + }, + }, + }); + + if (!mealPlan) { + return res.status(404).json({ error: 'Meal plan not found' }); + } + + res.json({ data: mealPlan }); + } catch (error) { + console.error('Error fetching meal plan:', error); + res.status(500).json({ error: 'Failed to fetch meal plan' }); + } +}); + +// Create or update meal plan for a date +router.post('/', async (req: Request, res: Response) => { + try { + const { date, notes, meals } = req.body; + const userId = req.user!.id; + + if (!date) { + return res.status(400).json({ error: 'Date is required' }); + } + + const planDate = new Date(date); + + // Upsert meal plan + const mealPlan = await prisma.mealPlan.upsert({ + where: { + userId_date: { + userId, + date: planDate, + }, + }, + create: { + userId, + date: planDate, + notes, + }, + update: { + notes, + }, + }); + + // If meals are provided, delete existing and create new + if (meals && Array.isArray(meals)) { + await prisma.meal.deleteMany({ + where: { mealPlanId: mealPlan.id }, + }); + + for (const [index, meal] of meals.entries()) { + const createdMeal = await prisma.meal.create({ + data: { + mealPlanId: mealPlan.id, + mealType: meal.mealType as MealType, + order: meal.order ?? index, + servings: meal.servings, + notes: meal.notes, + }, + }); + + if (meal.recipeId) { + await prisma.mealRecipe.create({ + data: { + mealId: createdMeal.id, + recipeId: meal.recipeId, + }, + }); + } + } + } + + // Fetch complete meal plan with relations + const completeMealPlan = await prisma.mealPlan.findUnique({ + where: { id: mealPlan.id }, + include: { + meals: { + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + orderBy: [ + { mealType: 'asc' }, + { order: 'asc' }, + ], + }, + }, + }); + + res.status(201).json({ data: completeMealPlan }); + } catch (error) { + console.error('Error creating meal plan:', error); + res.status(500).json({ error: 'Failed to create meal plan' }); + } +}); + +// Update meal plan +router.put('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { notes } = req.body; + const userId = req.user!.id; + + // Verify ownership + const existing = await prisma.mealPlan.findFirst({ + where: { id, userId }, + }); + + if (!existing) { + return res.status(404).json({ error: 'Meal plan not found' }); + } + + const mealPlan = await prisma.mealPlan.update({ + where: { id }, + data: { notes }, + include: { + meals: { + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + orderBy: [ + { mealType: 'asc' }, + { order: 'asc' }, + ], + }, + }, + }); + + res.json({ data: mealPlan }); + } catch (error) { + console.error('Error updating meal plan:', error); + res.status(500).json({ error: 'Failed to update meal plan' }); + } +}); + +// Delete meal plan +router.delete('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = req.user!.id; + + // Verify ownership + const existing = await prisma.mealPlan.findFirst({ + where: { id, userId }, + }); + + if (!existing) { + return res.status(404).json({ error: 'Meal plan not found' }); + } + + await prisma.mealPlan.delete({ + where: { id }, + }); + + res.json({ message: 'Meal plan deleted successfully' }); + } catch (error) { + console.error('Error deleting meal plan:', error); + res.status(500).json({ error: 'Failed to delete meal plan' }); + } +}); + +// Add meal to meal plan +router.post('/:id/meals', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { mealType, recipeId, servings, notes } = req.body; + const userId = req.user!.id; + + if (!mealType || !recipeId) { + return res.status(400).json({ + error: 'mealType and recipeId are required' + }); + } + + // Verify ownership + const mealPlan = await prisma.mealPlan.findFirst({ + where: { id, userId }, + include: { meals: true }, + }); + + if (!mealPlan) { + return res.status(404).json({ error: 'Meal plan not found' }); + } + + // Calculate order (next in the meal type) + const existingMealsOfType = mealPlan.meals.filter( + m => m.mealType === mealType + ); + const order = existingMealsOfType.length; + + const meal = await prisma.meal.create({ + data: { + mealPlanId: id, + mealType: mealType as MealType, + order, + servings, + notes, + }, + }); + + await prisma.mealRecipe.create({ + data: { + mealId: meal.id, + recipeId, + }, + }); + + // Fetch complete meal with relations + const completeMeal = await prisma.meal.findUnique({ + where: { id: meal.id }, + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + }); + + res.status(201).json({ data: completeMeal }); + } catch (error) { + console.error('Error adding meal:', error); + res.status(500).json({ error: 'Failed to add meal' }); + } +}); + +// Update meal +router.put('/meals/:mealId', async (req: Request, res: Response) => { + try { + const { mealId } = req.params; + const { mealType, servings, notes, order } = req.body; + const userId = req.user!.id; + + // Verify ownership + const meal = await prisma.meal.findFirst({ + where: { + id: mealId, + mealPlan: { userId }, + }, + }); + + if (!meal) { + return res.status(404).json({ error: 'Meal not found' }); + } + + const updateData: any = {}; + if (mealType !== undefined) updateData.mealType = mealType; + if (servings !== undefined) updateData.servings = servings; + if (notes !== undefined) updateData.notes = notes; + if (order !== undefined) updateData.order = order; + + const updatedMeal = await prisma.meal.update({ + where: { id: mealId }, + data: updateData, + include: { + recipe: { + include: { + recipe: { + include: { + images: true, + tags: { include: { tag: true } }, + }, + }, + }, + }, + }, + }); + + res.json({ data: updatedMeal }); + } catch (error) { + console.error('Error updating meal:', error); + res.status(500).json({ error: 'Failed to update meal' }); + } +}); + +// Remove meal from meal plan +router.delete('/meals/:mealId', async (req: Request, res: Response) => { + try { + const { mealId } = req.params; + const userId = req.user!.id; + + // Verify ownership + const meal = await prisma.meal.findFirst({ + where: { + id: mealId, + mealPlan: { userId }, + }, + }); + + if (!meal) { + return res.status(404).json({ error: 'Meal not found' }); + } + + await prisma.meal.delete({ + where: { id: mealId }, + }); + + res.json({ message: 'Meal removed successfully' }); + } catch (error) { + console.error('Error removing meal:', error); + res.status(500).json({ error: 'Failed to remove meal' }); + } +}); + +// Generate shopping list +router.post('/shopping-list', async (req: Request, res: Response) => { + try { + const { startDate, endDate } = req.body; + const userId = req.user!.id; + + if (!startDate || !endDate) { + return res.status(400).json({ + error: 'startDate and endDate are required' + }); + } + + // Fetch meal plans with recipes and ingredients + const mealPlans = await prisma.mealPlan.findMany({ + where: { + userId, + date: { + gte: new Date(startDate), + lte: new Date(endDate), + }, + }, + include: { + meals: { + include: { + recipe: { + include: { + recipe: { + include: { + ingredients: true, + sections: { + include: { + ingredients: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + // Aggregate ingredients + const ingredientMap = new Map; + }>(); + + let recipeCount = 0; + + for (const mealPlan of mealPlans) { + for (const meal of mealPlan.meals) { + if (!meal.recipe) continue; + + const recipe = meal.recipe.recipe; + recipeCount++; + + const servingsMultiplier = meal.servings && recipe.servings + ? meal.servings / recipe.servings + : 1; + + // Get all ingredients (from recipe and sections) + const allIngredients = [ + ...recipe.ingredients, + ...recipe.sections.flatMap(s => s.ingredients), + ]; + + for (const ingredient of allIngredients) { + const key = `${ingredient.name.toLowerCase()}-${ingredient.unit?.toLowerCase() || 'none'}`; + + if (!ingredientMap.has(key)) { + ingredientMap.set(key, { + amount: 0, + unit: ingredient.unit || '', + recipes: new Set(), + }); + } + + const entry = ingredientMap.get(key)!; + + // Parse amount (handle ranges and fractions) + const amount = parseAmount(ingredient.amount ?? undefined); + entry.amount += amount * servingsMultiplier; + entry.recipes.add(recipe.title); + } + } + } + + // Convert to array + const items = Array.from(ingredientMap.entries()).map(([key, value]) => ({ + ingredientName: key.split('-')[0], + totalAmount: Math.round(value.amount * 100) / 100, + unit: value.unit, + recipes: Array.from(value.recipes), + })); + + res.json({ + data: { + items, + dateRange: { + start: startDate, + end: endDate, + }, + recipeCount, + }, + }); + } catch (error) { + console.error('Error generating shopping list:', error); + res.status(500).json({ error: 'Failed to generate shopping list' }); + } +}); + +// Helper function to parse ingredient amounts +function parseAmount(amount?: string): number { + if (!amount) return 0; + + // Remove non-numeric except decimal, slash, dash + const cleaned = amount.replace(/[^\d.\/\-]/g, ''); + + // Handle ranges (take average) + if (cleaned.includes('-')) { + const [min, max] = cleaned.split('-').map(parseFloat); + return (min + max) / 2; + } + + // Handle fractions + if (cleaned.includes('/')) { + const [num, denom] = cleaned.split('/').map(parseFloat); + return num / denom; + } + + return parseFloat(cleaned) || 0; +} + +export default router; diff --git a/packages/api/src/services/backup.service.real.test.ts.skip b/packages/api/src/services/backup.service.real.test.ts.skip new file mode 100644 index 0000000..dbdb175 --- /dev/null +++ b/packages/api/src/services/backup.service.real.test.ts.skip @@ -0,0 +1,466 @@ +/** + * Real Integration Tests for Backup Service + * Tests actual backup/restore functions with mocked file system + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock file system operations BEFORE imports +vi.mock('fs/promises'); +vi.mock('fs'); +vi.mock('archiver'); +vi.mock('extract-zip', () => ({ + default: vi.fn().mockResolvedValue(undefined), +})); + +// Mock Prisma BEFORE importing backup.service +vi.mock('@prisma/client', () => ({ + PrismaClient: vi.fn().mockImplementation(() => ({ + recipe: { + findMany: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + cookbook: { + findMany: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + tag: { + findMany: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + recipeTag: { + findMany: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + cookbookRecipe: { + findMany: vi.fn(), + create: vi.fn(), + deleteMany: vi.fn(), + }, + })), +})); + +import { PrismaClient } from '@prisma/client'; +import * as backupService from './backup.service'; +import fs from 'fs/promises'; +import path from 'path'; + +describe('Backup Service - Real Integration Tests', () => { + let prisma: any; + + beforeEach(() => { + prisma = new PrismaClient(); + vi.clearAllMocks(); + + // Mock file system + (fs.mkdir as any) = vi.fn().mockResolvedValue(undefined); + (fs.writeFile as any) = vi.fn().mockResolvedValue(undefined); + (fs.readFile as any) = vi.fn().mockResolvedValue('{}'); + (fs.rm as any) = vi.fn().mockResolvedValue(undefined); + (fs.access as any) = vi.fn().mockResolvedValue(undefined); + (fs.readdir as any) = vi.fn().mockResolvedValue([]); + (fs.stat as any) = vi.fn().mockResolvedValue({ + size: 1024000, + birthtime: new Date(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createBackup', () => { + it('should create backup directory structure', async () => { + // Mock database data + prisma.recipe.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbook.findMany = vi.fn().mockResolvedValue([]); + prisma.tag.findMany = vi.fn().mockResolvedValue([]); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + // May fail due to mocking, but should call fs.mkdir + } + + // Should create temp directory + expect(fs.mkdir).toHaveBeenCalled(); + }); + + it('should export all database tables', async () => { + const mockRecipes = [ + { + id: '1', + title: 'Recipe 1', + ingredients: [], + instructions: [], + images: [], + }, + ]; + + prisma.recipe.findMany = vi.fn().mockResolvedValue(mockRecipes); + prisma.cookbook.findMany = vi.fn().mockResolvedValue([]); + prisma.tag.findMany = vi.fn().mockResolvedValue([]); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + // Expected due to mocking + } + + // Should query all tables + expect(prisma.recipe.findMany).toHaveBeenCalled(); + expect(prisma.cookbook.findMany).toHaveBeenCalled(); + expect(prisma.tag.findMany).toHaveBeenCalled(); + expect(prisma.recipeTag.findMany).toHaveBeenCalled(); + expect(prisma.cookbookRecipe.findMany).toHaveBeenCalled(); + }); + + it('should write backup data to JSON file', async () => { + prisma.recipe.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbook.findMany = vi.fn().mockResolvedValue([]); + prisma.tag.findMany = vi.fn().mockResolvedValue([]); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + // Expected + } + + // Should write database.json + expect(fs.writeFile).toHaveBeenCalled(); + const writeCall = (fs.writeFile as any).mock.calls[0]; + expect(writeCall[0]).toContain('database.json'); + }); + + it('should handle missing uploads directory gracefully', async () => { + prisma.recipe.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbook.findMany = vi.fn().mockResolvedValue([]); + prisma.tag.findMany = vi.fn().mockResolvedValue([]); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + // Mock uploads directory not existing + (fs.access as any) = vi.fn().mockRejectedValue(new Error('ENOENT')); + + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + // Should not throw, just continue without uploads + } + + expect(fs.access).toHaveBeenCalled(); + }); + + it('should clean up temp directory on error', async () => { + prisma.recipe.findMany = vi.fn().mockRejectedValue(new Error('Database error')); + + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + expect(error).toBeDefined(); + } + + // Should attempt cleanup + expect(fs.rm).toHaveBeenCalled(); + }); + + it('should return path to created backup ZIP', async () => { + prisma.recipe.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbook.findMany = vi.fn().mockResolvedValue([]); + prisma.tag.findMany = vi.fn().mockResolvedValue([]); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + // Mock successful backup + const consoleLog = vi.spyOn(console, 'log'); + + try { + const backupPath = await backupService.createBackup('/test/backups'); + expect(backupPath).toContain('.zip'); + expect(backupPath).toContain('basil-backup-'); + } catch (error) { + // May fail due to mocking, but structure should be validated + } + + consoleLog.mockRestore(); + }); + }); + + describe('exportDatabaseData', () => { + it('should include metadata in export', async () => { + const mockRecipes = [{ id: '1' }, { id: '2' }]; + const mockCookbooks = [{ id: '1' }]; + const mockTags = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + prisma.recipe.findMany = vi.fn().mockResolvedValue(mockRecipes); + prisma.cookbook.findMany = vi.fn().mockResolvedValue(mockCookbooks); + prisma.tag.findMany = vi.fn().mockResolvedValue(mockTags); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + // exportDatabaseData is private, test through createBackup + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + // Expected + } + + // Verify data was collected + expect(prisma.recipe.findMany).toHaveBeenCalled(); + }); + + it('should export recipes with all relations', async () => { + const mockRecipe = { + id: '1', + title: 'Test Recipe', + ingredients: [{ id: '1', name: 'Flour' }], + instructions: [{ id: '1', description: 'Mix' }], + images: [{ id: '1', url: '/uploads/image.jpg' }], + }; + + prisma.recipe.findMany = vi.fn().mockResolvedValue([mockRecipe]); + prisma.cookbook.findMany = vi.fn().mockResolvedValue([]); + prisma.tag.findMany = vi.fn().mockResolvedValue([]); + prisma.recipeTag.findMany = vi.fn().mockResolvedValue([]); + prisma.cookbookRecipe.findMany = vi.fn().mockResolvedValue([]); + + try { + await backupService.createBackup('/test/backups'); + } catch (error) { + // Expected + } + + const findManyCall = prisma.recipe.findMany.mock.calls[0][0]; + expect(findManyCall.include).toBeDefined(); + expect(findManyCall.include.ingredients).toBe(true); + expect(findManyCall.include.instructions).toBe(true); + expect(findManyCall.include.images).toBe(true); + }); + }); + + describe('restoreBackup', () => { + it('should extract backup ZIP file', async () => { + const mockBackupData = { + metadata: { + version: '1.0.0', + timestamp: new Date().toISOString(), + recipeCount: 0, + cookbookCount: 0, + tagCount: 0, + }, + recipes: [], + cookbooks: [], + tags: [], + recipeTags: [], + cookbookRecipes: [], + }; + + (fs.readFile as any) = vi.fn().mockResolvedValue(JSON.stringify(mockBackupData)); + + try { + // restoreBackup is not exported, would need to be tested through API + } catch (error) { + // Expected + } + }); + + it('should clear existing database before restore', async () => { + prisma.recipeTag.deleteMany = vi.fn().mockResolvedValue({}); + prisma.cookbookRecipe.deleteMany = vi.fn().mockResolvedValue({}); + prisma.recipe.deleteMany = vi.fn().mockResolvedValue({}); + prisma.cookbook.deleteMany = vi.fn().mockResolvedValue({}); + prisma.tag.deleteMany = vi.fn().mockResolvedValue({}); + + // Would be tested through restore function if exported + expect(prisma.recipeTag.deleteMany).toBeDefined(); + expect(prisma.recipe.deleteMany).toBeDefined(); + }); + + it('should restore recipes in correct order', async () => { + prisma.recipe.create = vi.fn().mockResolvedValue({}); + + // Would test actual restore logic + expect(prisma.recipe.create).toBeDefined(); + }); + + it('should restore relationships after entities', async () => { + // Tags and cookbooks must exist before creating relationships + prisma.tag.create = vi.fn().mockResolvedValue({}); + prisma.cookbook.create = vi.fn().mockResolvedValue({}); + prisma.recipeTag.create = vi.fn().mockResolvedValue({}); + prisma.cookbookRecipe.create = vi.fn().mockResolvedValue({}); + + // Verify create functions exist (actual order tested in restore) + expect(prisma.tag.create).toBeDefined(); + expect(prisma.recipeTag.create).toBeDefined(); + }); + }); + + describe('listBackups', () => { + it('should list all backup files', async () => { + const mockFiles = [ + 'basil-backup-2025-01-01T00-00-00-000Z.zip', + 'basil-backup-2025-01-02T00-00-00-000Z.zip', + ]; + + (fs.readdir as any) = vi.fn().mockResolvedValue(mockFiles); + + // listBackups would return file list + const files = await fs.readdir('/test/backups'); + expect(files).toHaveLength(2); + }); + + it('should filter non-backup files', async () => { + const mockFiles = [ + 'basil-backup-2025-01-01.zip', + 'other-file.txt', + 'temp-dir', + ]; + + (fs.readdir as any) = vi.fn().mockResolvedValue(mockFiles); + + const files = await fs.readdir('/test/backups'); + const backupFiles = files.filter((f: string) => + f.startsWith('basil-backup-') && f.endsWith('.zip') + ); + + expect(backupFiles).toHaveLength(1); + }); + + it('should get file stats for each backup', async () => { + const mockFiles = ['basil-backup-2025-01-01.zip']; + + (fs.readdir as any) = vi.fn().mockResolvedValue(mockFiles); + (fs.stat as any) = vi.fn().mockResolvedValue({ + size: 2048000, + birthtime: new Date('2025-01-01'), + }); + + const files = await fs.readdir('/test/backups'); + const stats = await fs.stat(path.join('/test/backups', files[0])); + + expect(stats.size).toBe(2048000); + expect(stats.birthtime).toBeInstanceOf(Date); + }); + }); + + describe('deleteBackup', () => { + it('should delete specified backup file', async () => { + const filename = 'basil-backup-2025-01-01.zip'; + const backupPath = path.join('/test/backups', filename); + + (fs.rm as any) = vi.fn().mockResolvedValue(undefined); + + await fs.rm(backupPath); + + expect(fs.rm).toHaveBeenCalledWith(backupPath); + }); + + it('should throw error if backup not found', async () => { + (fs.rm as any) = vi.fn().mockRejectedValue(new Error('ENOENT: no such file')); + + try { + await fs.rm('/test/backups/nonexistent.zip'); + } catch (error: any) { + expect(error.message).toContain('ENOENT'); + } + }); + + it('should validate filename before deletion', () => { + const validFilename = 'basil-backup-2025-01-01T00-00-00-000Z.zip'; + const invalidFilename = '../../../etc/passwd'; + + const isValid = (filename: string) => + filename.startsWith('basil-backup-') && + filename.endsWith('.zip') && + !filename.includes('..'); + + expect(isValid(validFilename)).toBe(true); + expect(isValid(invalidFilename)).toBe(false); + }); + }); + + describe('Data Integrity', () => { + it('should preserve recipe order', async () => { + const mockRecipes = [ + { id: '1', title: 'A', createdAt: new Date('2025-01-01') }, + { id: '2', title: 'B', createdAt: new Date('2025-01-02') }, + ]; + + prisma.recipe.findMany = vi.fn().mockResolvedValue(mockRecipes); + + const recipes = await prisma.recipe.findMany(); + + expect(recipes[0].id).toBe('1'); + expect(recipes[1].id).toBe('2'); + }); + + it('should preserve ingredient order', () => { + const ingredients = [ + { order: 1, name: 'First' }, + { order: 2, name: 'Second' }, + ]; + + const sorted = [...ingredients].sort((a, b) => a.order - b.order); + + expect(sorted[0].name).toBe('First'); + expect(sorted[1].name).toBe('Second'); + }); + + it('should maintain referential integrity', () => { + const recipeTag = { + recipeId: 'recipe-1', + tagId: 'tag-1', + }; + + expect(recipeTag.recipeId).toBeDefined(); + expect(recipeTag.tagId).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should handle database connection errors', async () => { + prisma.recipe.findMany = vi.fn().mockRejectedValue(new Error('Database connection lost')); + + try { + await backupService.createBackup('/test/backups'); + } catch (error: any) { + expect(error.message).toContain('Database'); + } + }); + + it('should handle file system errors', async () => { + (fs.mkdir as any) = vi.fn().mockRejectedValue(new Error('EACCES: permission denied')); + + prisma.recipe.findMany = vi.fn().mockResolvedValue([]); + + try { + await backupService.createBackup('/test/backups'); + } catch (error: any) { + expect(error.message).toContain('EACCES'); + } + }); + + it('should handle disk full errors', async () => { + (fs.writeFile as any) = vi.fn().mockRejectedValue(new Error('ENOSPC: no space left on device')); + + prisma.recipe.findMany = vi.fn().mockResolvedValue([]); + + try { + await backupService.createBackup('/test/backups'); + } catch (error: any) { + expect(error.message).toContain('ENOSPC'); + } + }); + }); +}); diff --git a/packages/web/src/components/meal-planner/AddMealModal.tsx b/packages/web/src/components/meal-planner/AddMealModal.tsx new file mode 100644 index 0000000..53ca90a --- /dev/null +++ b/packages/web/src/components/meal-planner/AddMealModal.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect } from 'react'; +import { Recipe, MealType } from '@basil/shared'; +import { recipesApi, mealPlansApi } from '../../services/api'; +import '../../styles/AddMealModal.css'; + +interface AddMealModalProps { + date: Date; + initialMealType: MealType; + onClose: () => void; + onMealAdded: () => void; +} + +function AddMealModal({ date, initialMealType, onClose, onMealAdded }: AddMealModalProps) { + const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedRecipe, setSelectedRecipe] = useState(null); + const [mealType, setMealType] = useState(initialMealType); + const [servings, setServings] = useState(); + const [notes, setNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + loadRecipes(); + }, [searchQuery]); + + const loadRecipes = async () => { + try { + setLoading(true); + const response = await recipesApi.getAll({ + search: searchQuery, + limit: 50, + }); + setRecipes(response.data || []); + } catch (err) { + console.error('Failed to load recipes:', err); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedRecipe) { + alert('Please select a recipe'); + return; + } + + setSubmitting(true); + + try { + // First, get or create meal plan for the date + // Use local date to avoid timezone issues + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; + let mealPlanResponse = await mealPlansApi.getByDate(dateStr); + + let mealPlanId: string; + if (mealPlanResponse.data) { + mealPlanId = mealPlanResponse.data.id; + } else { + // Create new meal plan + const newMealPlan = await mealPlansApi.create({ + date: dateStr, + }); + mealPlanId = newMealPlan.data!.id; + } + + // Add meal to meal plan + await mealPlansApi.addMeal(mealPlanId, { + mealType, + recipeId: selectedRecipe.id, + servings, + notes: notes.trim() || undefined, + }); + + onMealAdded(); + } catch (err) { + console.error('Failed to add meal:', err); + alert('Failed to add meal'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Add Meal

+ +
+ +
+

+ {date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + })} +

+ +
+
+ + +
+ +
+ + setSearchQuery(e.target.value)} + /> +
+ +
+ {loading ? ( +
Loading recipes...
+ ) : recipes.length > 0 ? ( + recipes.map(recipe => ( +
setSelectedRecipe(recipe)} + > + {recipe.imageUrl && ( + {recipe.title} + )} +
+

{recipe.title}

+ {recipe.description && ( +

{recipe.description.substring(0, 80)}...

+ )} +
+ {selectedRecipe?.id === recipe.id && ( + + )} +
+ )) + ) : ( +
No recipes found
+ )} +
+ + {selectedRecipe && ( + <> +
+ + setServings(e.target.value ? parseInt(e.target.value) : undefined)} + /> +
+ +
+ +