import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Fastify from 'fastify'; import { fastifyAwilixPlugin } from '@fastify/awilix'; import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import { MealPlanStatus } from '@meshitrack/shared'; vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => 'mock-jwks'), jwtVerify: vi.fn().mockResolvedValue({ payload: { sub: 'kc-1', email: 'test@example.com', preferred_username: 'testuser', realm_access: { roles: ['member'] }, householdIds: ['hh1'], }, protectedHeader: { alg: 'RS256' }, key: {}, }), })); const { mockFindByHousehold, mockFindById, mockFindByWeek, mockCreate, mockUpdate, mockUpdateStatus, mockDelete, } = vi.hoisted(() => ({ mockFindByHousehold: vi.fn(), mockFindById: vi.fn(), mockFindByWeek: vi.fn(), mockCreate: vi.fn(), mockUpdate: vi.fn(), mockUpdateStatus: vi.fn(), mockDelete: vi.fn(), })); vi.mock('./meal-plans.repository.js', () => ({ MealPlanRepository: class { findByHousehold = mockFindByHousehold; findById = mockFindById; findByWeek = mockFindByWeek; create = mockCreate; update = mockUpdate; updateStatus = mockUpdateStatus; delete = mockDelete; }, })); // Mock prerequisite repositories to allow SuggestionEngine/Gap to resolve vi.mock('../recipes/recipes.repository.js', () => ({ RecipesRepository: class { findByHousehold = vi.fn().mockResolvedValue({ data: [] }); findById = vi.fn(); }, })); vi.mock('../pantry/pantry.repository.js', () => ({ PantryRepository: class { findActiveByHousehold = vi.fn().mockResolvedValue([]); }, })); vi.mock('../nutrition-targets/nutrition-target.repository.js', () => ({ NutritionTargetRepository: class { findByUser = vi.fn().mockResolvedValue(null); }, })); vi.mock('../products/products.repository.js', () => ({ ProductsRepository: class { findByIds = vi.fn().mockResolvedValue([]); }, })); vi.mock('../users/users.repository.js', () => ({ UsersRepository: class { findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); }, })); import authPlugin from '../../plugins/auth.plugin.js'; import householdPlugin from '../../plugins/household.plugin.js'; import usersRoutes from '../users/users.routes.js'; import mealPlanRoutes from './meal-plans.routes.js'; const emptyNutrition = { calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, sugar: 0, sodium: 0, saturatedFat: 0, cholesterol: 0 }; function makePlan(overrides = {}) { return { _id: 'plan-1', householdId: 'hh1', weekStartDate: '2026-05-10', days: Array.from({ length: 7 }, (_, i) => ({ date: `2026-05-${10 + i}`, meals: [], dailyNutritionTotal: emptyNutrition, })), status: MealPlanStatus.DRAFT, createdBy: 'kc-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('meal-plan.routes', () => { let app: any; async function buildTestApp() { const instance = Fastify({ logger: false }); instance.setValidatorCompiler(validatorCompiler); instance.setSerializerCompiler(serializerCompiler); await instance.register(fastifyAwilixPlugin, { disposeOnClose: true, disposeOnResponse: true, strictBooleanEnforced: true, }); await instance.register(authPlugin); await instance.register(householdPlugin); await instance.register(usersRoutes); await instance.register(mealPlanRoutes); await instance.ready(); return instance; } const authHeaders = { authorization: 'Bearer valid-token' }; beforeEach(async () => { vi.clearAllMocks(); app = await buildTestApp(); }); afterEach(async () => { if (app) await app.close(); }); describe('GET /api/v1/households/:householdId/meal-plans', () => { it('returns paginated results', async () => { mockFindByHousehold.mockResolvedValue({ data: [makePlan()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/meal-plans', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0]._id).toBe('plan-1'); }); }); describe('GET /api/v1/households/:householdId/meal-plans/week/:weekStartDate', () => { it('returns matched weekly plan', async () => { mockFindByWeek.mockResolvedValue(makePlan()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/meal-plans/week/2026-05-10', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json()._id).toBe('plan-1'); }); it('returns not-found message structure if missing', async () => { mockFindByWeek.mockResolvedValue(null); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/meal-plans/week/2026-05-10', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().message).toBe('No meal plan scheduled for this week'); }); }); describe('POST /api/v1/households/:householdId/meal-plans', () => { it('creates a new plan', async () => { mockFindByWeek.mockResolvedValue(null); mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-plan-id', createdAt: new Date(), updatedAt: new Date() })); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/meal-plans', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ weekStartDate: '2026-05-10', days: Array.from({ length: 7 }, (_, i) => ({ date: `2026-05-${10 + i}`, meals: [], dailyNutritionTotal: emptyNutrition, })), status: 'draft', }), }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body._id).toBe('new-plan-id'); expect(body.status).toBe('draft'); }); }); describe('GET /api/v1/households/:householdId/meal-plans/suggestions', () => { it('returns list of scored recipe recommendations', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/meal-plans/suggestions?limit=2', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); }); describe('GET /api/v1/households/:householdId/meal-plans/:id/gap', () => { it('returns missing elements report', async () => { mockFindById.mockResolvedValue(makePlan()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/meal-plans/plan-1/gap', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.mealPlanId).toBe('plan-1'); expect(Array.isArray(body.missingItems)).toBe(true); }); }); });