import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MealPlanService } from './meal-plans.service.js'; import type { MealPlanRepository } from './meal-plans.repository.js'; import { MealPlanStatus, MealType } from '@meshitrack/shared'; import { BadRequestError, NotFoundError } from '../../common/errors.js'; describe(MealPlanService.name, () => { let service: MealPlanService; let mockRepo: { [K in keyof MealPlanRepository]: ReturnType }; beforeEach(() => { mockRepo = { findByHousehold: vi.fn(), findById: vi.fn(), findByWeek: vi.fn(), create: vi.fn(), update: vi.fn(), updateStatus: vi.fn(), delete: vi.fn(), } as never; service = new MealPlanService({ mealPlanRepository: mockRepo as unknown as MealPlanRepository, }); }); describe('list', () => { it('delegates to repository', async () => { const query = { limit: 10 }; const mockResult = { data: [], pagination: { cursor: null, hasMore: false } }; mockRepo.findByHousehold.mockResolvedValue(mockResult); const result = await service.list('hh1', query); expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', query); expect(result).toEqual(mockResult); }); }); describe('getById', () => { it('returns plan if found', async () => { const mockPlan = { _id: 'p1' }; mockRepo.findById.mockResolvedValue(mockPlan); const result = await service.getById('p1', 'hh1'); expect(mockRepo.findById).toHaveBeenCalledWith('p1', 'hh1'); expect(result).toEqual(mockPlan); }); it('throws NotFoundError if not found', async () => { mockRepo.findById.mockResolvedValue(null); await expect(service.getById('p1', 'hh1')).rejects.toThrow(NotFoundError); }); }); describe('create', () => { const mockPerServingNutrition = { calories: 100, protein: 10, carbs: 20, fat: 5, fiber: 2, sugar: 3, sodium: 100, saturatedFat: 1, cholesterol: 10, }; const emptyDays = Array.from({ length: 7 }, (_, i) => ({ date: `2026-05-${10 + i}`, meals: [], dailyNutritionTotal: { calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, sugar: 0, sodium: 0, saturatedFat: 0, cholesterol: 0, }, })); it('calculates day totals and delegates to repository', async () => { mockRepo.findByWeek.mockResolvedValue(null); mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' })); const daysWithMeal = [...emptyDays]; daysWithMeal[0] = { date: '2026-05-10', meals: [ { id: 'meal-uuid-1', type: MealType.BREAKFAST, recipeName: 'Eggs', servings: 2, perServingNutrition: mockPerServingNutrition, }, ], // Let's deliberately pass incorrect values to verify the service forces recalculation! dailyNutritionTotal: { calories: 999, protein: 99, carbs: 99, fat: 99 }, }; const input = { weekStartDate: '2026-05-10', days: daysWithMeal, status: MealPlanStatus.DRAFT, }; const result = await service.create('hh1', 'user1', input); expect(mockRepo.findByWeek).toHaveBeenCalledWith('hh1', '2026-05-10'); expect(mockRepo.create).toHaveBeenCalled(); // Verify recalculation happened (perServing x 2 servings) expect(result.days[0].dailyNutritionTotal).toEqual({ calories: 200, protein: 20, carbs: 40, fat: 10, fiber: 4, sugar: 6, sodium: 200, saturatedFat: 2, cholesterol: 20, }); }); it('uses customNutrition over perServingNutrition if present', async () => { mockRepo.findByWeek.mockResolvedValue(null); mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' })); const daysWithCustom = [...emptyDays]; daysWithCustom[1] = { date: '2026-05-11', meals: [ { id: 'meal-uuid-2', type: MealType.LUNCH, recipeName: 'Custom Item', servings: 1, perServingNutrition: mockPerServingNutrition, // 100 calories customNutrition: { calories: 300, protein: 30, carbs: 5, fat: 15, }, }, ], dailyNutritionTotal: { calories: 0, protein: 0, carbs: 0, fat: 0 }, }; const input = { weekStartDate: '2026-05-10', days: daysWithCustom, status: MealPlanStatus.DRAFT, }; const result = await service.create('hh1', 'user1', input); expect(result.days[1].dailyNutritionTotal.calories).toBe(300); expect(result.days[1].dailyNutritionTotal.protein).toBe(30); }); it('throws BadRequestError if plan already exists for the week', async () => { mockRepo.findByWeek.mockResolvedValue({ _id: 'existing-id' }); const input = { weekStartDate: '2026-05-10', days: emptyDays, status: MealPlanStatus.DRAFT, }; await expect(service.create('hh1', 'user1', input)).rejects.toThrow(BadRequestError); }); }); describe('update', () => { const existingPlan = { _id: 'p1', householdId: 'hh1', status: MealPlanStatus.DRAFT }; beforeEach(() => { mockRepo.findById.mockResolvedValue(existingPlan); }); it('updates values and recalculates days if updated', async () => { mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data })); const emptyDays = Array.from({ length: 7 }, (_, i) => ({ date: `2026-05-${10 + i}`, meals: [], dailyNutritionTotal: { calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, sugar: 0, sodium: 0, saturatedFat: 0, cholesterol: 0, }, })); const result = await service.update('p1', 'hh1', { status: MealPlanStatus.ACTIVE, days: emptyDays, }); expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { status: MealPlanStatus.ACTIVE, days: emptyDays, }); expect(result.status).toBe(MealPlanStatus.ACTIVE); }); it('supports updating shoppingListId', async () => { mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data })); const result = await service.update('p1', 'hh1', { shoppingListId: 'sl-1' }); expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { shoppingListId: 'sl-1' }); expect((result as any).shoppingListId).toBe('sl-1'); }); it('throws NotFoundError if update returns null', async () => { mockRepo.update.mockResolvedValue(null); await expect(service.update('p1', 'hh1', { status: MealPlanStatus.ACTIVE })).rejects.toThrow(NotFoundError); }); }); describe('updateStatus', () => { it('delegates update status to repository', async () => { mockRepo.findById.mockResolvedValue({ _id: 'p1' }); mockRepo.updateStatus.mockResolvedValue({ _id: 'p1', status: MealPlanStatus.ARCHIVED }); const result = await service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED); expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED); expect(result.status).toBe(MealPlanStatus.ARCHIVED); }); it('throws NotFoundError if updateStatus returns null', async () => { mockRepo.findById.mockResolvedValue({ _id: 'p1' }); mockRepo.updateStatus.mockResolvedValue(null); await expect(service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED)).rejects.toThrow(NotFoundError); }); }); describe('delete', () => { it('delegates deletion if found', async () => { mockRepo.findById.mockResolvedValue({ _id: 'p1' }); mockRepo.delete.mockResolvedValue({ _id: 'p1' }); const result = await service.delete('p1', 'hh1'); expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1'); expect(result).toEqual({ _id: 'p1' }); }); it('throws NotFoundError if delete returns null', async () => { mockRepo.findById.mockResolvedValue({ _id: 'p1' }); mockRepo.delete.mockResolvedValue(null); await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError); }); }); });