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'; 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 { mockFindByUser, mockFindAllByUser, mockDeactivateAllForUser, mockCreate, } = vi.hoisted(() => ({ mockFindByUser: vi.fn(), mockFindAllByUser: vi.fn(), mockDeactivateAllForUser: vi.fn(), mockCreate: vi.fn(), })); vi.mock('./nutrition-target.repository.js', () => ({ NutritionTargetRepository: class { findByUser = mockFindByUser; findAllByUser = mockFindAllByUser; deactivateAllForUser = mockDeactivateAllForUser; create = mockCreate; }, })); 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 nutritionTargetRoutes from './nutrition-target.routes.js'; function makeTarget(overrides = {}) { return { _id: 'target-1', userId: 'kc-1', householdId: 'hh1', dailyCalories: 2000, proteinG: 150, carbsG: 200, fatG: 67, isActive: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('nutrition-target.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(nutritionTargetRoutes); 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/nutrition-targets', () => { it('returns the active target if found', async () => { mockFindByUser.mockResolvedValue(makeTarget()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/nutrition-targets', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.dailyCalories).toBe(2000); expect(body.isActive).toBe(true); }); it('returns a message object if no target is set', async () => { mockFindByUser.mockResolvedValue(null); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/nutrition-targets', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().message).toBe('No active targets defined'); }); }); describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => { it('returns historical targets', async () => { mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/nutrition-targets/history', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveLength(2); }); }); describe('POST /api/v1/households/:householdId/nutrition-targets', () => { it('creates target and returns it', async () => { mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id', createdAt: new Date(), updatedAt: new Date() })); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/nutrition-targets', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ dailyCalories: 1800, proteinG: 135, carbsG: 180, fatG: 60, }), }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.dailyCalories).toBe(1800); expect(body.isActive).toBe(true); // auto activated }); }); describe('POST /api/v1/households/:householdId/nutrition-targets/presets', () => { it('returns calculated splits', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/nutrition-targets/presets', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ calories: 2000, strategy: 'loss', }), }); expect(res.statusCode).toBe(200); const body = res.json(); // Loss is 40% protein (200g), 30% carbs (150g), 30% fat (67g) expect(body.dailyCalories).toBe(2000); expect(body.proteinG).toBe(200); }); }); });