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 { mockFindByHousehold, mockFindById, mockFindByProductId, mockFindAllByProductId, mockCreate, mockUpdate, mockSoftDelete, } = vi.hoisted(() => ({ mockFindByHousehold: vi.fn(), mockFindById: vi.fn(), mockFindByProductId: vi.fn(), mockFindAllByProductId: vi.fn(), mockCreate: vi.fn(), mockUpdate: vi.fn(), mockSoftDelete: vi.fn(), })); const { mockFindByIds } = vi.hoisted(() => ({ mockFindByIds: vi.fn(), })); vi.mock('./recipes.repository.js', () => ({ RecipesRepository: class { findByHousehold = mockFindByHousehold; findById = mockFindById; findByProductId = mockFindByProductId; findAllByProductId = mockFindAllByProductId; create = mockCreate; update = mockUpdate; softDelete = mockSoftDelete; }, })); vi.mock('../products/products.repository.js', () => ({ ProductsRepository: class { findByIds = mockFindByIds; findById = vi.fn(); }, })); 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 recipesRoutes from './recipes.routes.js'; const nutrition = { calories: 200, protein: 20, carbs: 10, fat: 8 }; function makeProduct(id = 'p1') { return { _id: id, householdId: 'hh1', servingSize: 100, servingUnit: 'g', nutrition, }; } function makeRecipe(overrides: Record = {}) { return { _id: 'recipe-1', householdId: 'hh1', name: 'Grilled Chicken', servings: 2, ingredients: [ { productId: 'p1', productName: 'Chicken Breast', quantity: 200, unit: 'g', isOptional: false, nutritionContribution: nutrition, }, ], steps: [{ order: 1, instruction: 'Grill the chicken.' }], tags: [], isFavorite: false, totalNutrition: nutrition, perServingNutrition: nutrition, warnings: [], createdBy: 'kc-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('recipes.routes', () => { let app: Awaited>; 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(recipesRoutes); 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/recipes', () => { it('returns paginated list', async () => { mockFindByHousehold.mockResolvedValue({ data: [makeRecipe()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].name).toBe('Grilled Chicken'); expect(body.pagination.hasMore).toBe(false); }); it('returns empty list', async () => { mockFindByHousehold.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().data).toHaveLength(0); }); }); describe('GET /api/v1/households/:householdId/recipes/:id', () => { it('returns recipe when found', async () => { mockFindById.mockResolvedValue(makeRecipe()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes/recipe-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().name).toBe('Grilled Chicken'); }); it('returns recipe with all optional fields', async () => { mockFindById.mockResolvedValue( makeRecipe({ description: 'A delicious dish', prepTime: 10, cookTime: 20, totalTime: 30, cuisine: 'Italian', imageUrl: 'https://example.com/image.jpg', source: { type: 'url', url: 'https://example.com/recipe', importedAt: new Date('2024-01-01'), }, ingredients: [ { productId: 'p1', productName: 'Chicken Breast', quantity: 200, unit: 'g', originalQuantity: 7, originalUnit: 'oz', preparation: 'diced', isOptional: false, nutritionContribution: nutrition, }, ], steps: [{ order: 1, instruction: 'Prep.', duration: 5, tip: 'Use sharp knife.' }], }), ); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes/recipe-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.description).toBe('A delicious dish'); expect(body.prepTime).toBe(10); expect(body.cookTime).toBe(20); expect(body.totalTime).toBe(30); expect(body.cuisine).toBe('Italian'); expect(body.imageUrl).toBe('https://example.com/image.jpg'); expect(body.source.type).toBe('url'); expect(body.source.url).toBe('https://example.com/recipe'); expect(body.source.importedAt).toBeDefined(); expect(body.ingredients[0].originalQuantity).toBe(7); expect(body.ingredients[0].originalUnit).toBe('oz'); expect(body.ingredients[0].preparation).toBe('diced'); expect(body.steps[0].duration).toBe(5); expect(body.steps[0].tip).toBe('Use sharp knife.'); }); it('returns recipe with source but no url or importedAt', async () => { mockFindById.mockResolvedValue( makeRecipe({ source: { type: 'manual' }, }), ); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes/recipe-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.source.type).toBe('manual'); expect(body.source.url).toBeUndefined(); expect(body.source.importedAt).toBeUndefined(); }); it('returns recipe with source.importedAt as string', async () => { mockFindById.mockResolvedValue( makeRecipe({ source: { type: 'url', url: 'https://example.com', importedAt: '2024-01-01T00:00:00.000Z', }, }), ); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes/recipe-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().source.importedAt).toBe('2024-01-01T00:00:00.000Z'); }); it('returns 404 when not found', async () => { mockFindById.mockResolvedValue(null); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes/missing', headers: authHeaders, }); expect(res.statusCode).toBe(404); }); }); describe('POST /api/v1/households/:householdId/recipes', () => { it('creates a recipe with metric ingredients', async () => { mockFindByIds.mockResolvedValue([makeProduct()]); mockCreate.mockResolvedValue(makeRecipe()); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/recipes', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Grilled Chicken', servings: 2, ingredients: [ { productId: 'p1', productName: 'Chicken Breast', quantity: 200, unit: 'g', isOptional: false, }, ], steps: [{ order: 1, instruction: 'Grill the chicken.' }], }), }); expect(res.statusCode).toBe(201); expect(res.json().name).toBe('Grilled Chicken'); }); it('rejects missing name', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/recipes', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ servings: 2, ingredients: [], steps: [] }), }); expect(res.statusCode).toBe(400); }); }); describe('PATCH /api/v1/households/:householdId/recipes/:id', () => { it('updates a recipe name', async () => { const updated = makeRecipe({ name: 'Updated Recipe' }); mockFindById.mockResolvedValue(makeRecipe()); mockUpdate.mockResolvedValue(updated); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/recipes/recipe-1', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Updated Recipe' }), }); expect(res.statusCode).toBe(200); expect(res.json().name).toBe('Updated Recipe'); }); it('updates recipe with new ingredients and recalculates', async () => { const updated = makeRecipe({ name: 'Grilled Chicken' }); mockFindById.mockResolvedValue(makeRecipe()); mockFindByIds.mockResolvedValue([makeProduct()]); mockUpdate.mockResolvedValue(updated); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/recipes/recipe-1', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ ingredients: [ { productId: 'p1', productName: 'Chicken Breast', quantity: 300, unit: 'g', isOptional: false, }, ], }), }); expect(res.statusCode).toBe(200); }); }); describe('DELETE /api/v1/households/:householdId/recipes/:id', () => { it('soft-deletes a recipe', async () => { mockFindById.mockResolvedValue(makeRecipe()); mockSoftDelete.mockResolvedValue(makeRecipe()); const res = await app.inject({ method: 'DELETE', url: '/api/v1/households/hh1/recipes/recipe-1', headers: authHeaders, }); expect(res.statusCode).toBe(204); }); }); describe('POST /api/v1/households/:householdId/recipes/:id/scale', () => { it('returns scaled recipe preview', async () => { mockFindById.mockResolvedValue(makeRecipe()); mockFindByIds.mockResolvedValue([makeProduct()]); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/recipes/recipe-1/scale', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ targetServings: 4 }), }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.servings).toBe(4); expect(body.ingredients[0].quantity).toBe(400); }); }); describe('POST /api/v1/households/:householdId/recipes/import-text', () => { it('returns available:false with NoOp provider', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/recipes/import-text', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'Some recipe text' }), }); expect(res.statusCode).toBe(200); expect(res.json().available).toBe(false); }); }); describe('POST /api/v1/households/:householdId/recipes/import-url', () => { it('returns available:false with NoOp provider', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/recipes/import-url', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ url: 'https://example.com/recipe' }), }); expect(res.statusCode).toBe(200); expect(res.json().available).toBe(false); }); }); describe('GET /api/v1/households/:householdId/recipes/by-product/:productId', () => { it('returns recipes using a product', async () => { mockFindByProductId.mockResolvedValue({ data: [makeRecipe()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/recipes/by-product/p1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); }); }); });