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, mockFindExpiringSoon, mockCreate, mockUpdate, mockDelete, mockGetWasteStats, mockGetTopWastedProducts, mockFindByIds, mockBulkUpdateStatus, mockFindActiveByHousehold, mockUpdateFreshness, } = vi.hoisted(() => ({ mockFindByHousehold: vi.fn(), mockFindById: vi.fn(), mockFindExpiringSoon: vi.fn(), mockCreate: vi.fn(), mockUpdate: vi.fn(), mockDelete: vi.fn(), mockGetWasteStats: vi.fn(), mockGetTopWastedProducts: vi.fn(), mockFindByIds: vi.fn(), mockBulkUpdateStatus: vi.fn(), mockFindActiveByHousehold: vi.fn(), mockUpdateFreshness: vi.fn(), })); const { mockProductFindById } = vi.hoisted(() => ({ mockProductFindById: vi.fn(), })); const { mockFindApplicableRule } = vi.hoisted(() => ({ mockFindApplicableRule: vi.fn(), })); vi.mock('../../../src/modules/pantry/pantry.repository.js', () => ({ PantryRepository: class { findByHousehold = mockFindByHousehold; findById = mockFindById; findExpiringSoon = mockFindExpiringSoon; findActiveByHousehold = mockFindActiveByHousehold; create = mockCreate; update = mockUpdate; updateFreshness = mockUpdateFreshness; delete = mockDelete; getWasteStats = mockGetWasteStats; getTopWastedProducts = mockGetTopWastedProducts; findByIds = mockFindByIds; bulkUpdateStatus = mockBulkUpdateStatus; }, })); vi.mock('../../../src/modules/products/products.repository.js', () => ({ ProductsRepository: class { findById = mockProductFindById; findByIds = vi.fn(); }, })); vi.mock('../../../src/modules/freshness-rules/freshness-rules.repository.js', () => ({ FreshnessRulesRepository: class { findApplicableRule = mockFindApplicableRule; findByHousehold = vi.fn(); findById = vi.fn(); create = vi.fn(); update = vi.fn(); delete = vi.fn(); }, })); vi.mock('../../../src/modules/users/users.repository.js', () => ({ UsersRepository: class { findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); }, })); import authPlugin from '../../../src/plugins/auth.plugin.js'; import householdPlugin from '../../../src/plugins/household.plugin.js'; import usersRoutes from '../../../src/modules/users/users.routes.js'; import pantryRoutes from '../../../src/modules/pantry/pantry.routes.js'; const freshness = { estimatedExpiryDate: new Date('2024-02-01').toISOString(), daysRemaining: 14, urgency: 'fresh', source: 'rule', }; function makeItem(overrides: Record = {}) { return { _id: 'item-1', householdId: 'hh1', productId: 'p1', productName: 'Milk', storageLocation: 'fridge', quantity: 1, unit: 'piece', purchaseDate: new Date('2024-01-01').toISOString(), status: 'sealed', freshnessEstimate: freshness, createdBy: 'kc-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('pantry.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(pantryRoutes); 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 /pantry', () => { it('returns paginated list', async () => { mockFindByHousehold.mockResolvedValue({ data: [makeItem()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/pantry', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().data).toHaveLength(1); }); 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/pantry', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().data).toHaveLength(0); }); }); describe('GET /pantry/expiring-soon', () => { it('returns expiring items', async () => { mockFindExpiringSoon.mockResolvedValue({ data: [makeItem()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/pantry/expiring-soon', headers: authHeaders, }); expect(res.statusCode).toBe(200); }); }); describe('GET /pantry/stats', () => { it('returns waste stats', async () => { mockGetWasteStats.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]); mockGetTopWastedProducts.mockResolvedValue([]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/pantry/stats', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.totalItemsConsumed).toBe(5); expect(body.wastePercentage).toBeCloseTo(28.57, 1); }); }); describe('GET /pantry/:id', () => { it('returns item', async () => { mockFindById.mockResolvedValue(makeItem()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/pantry/item-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().productName).toBe('Milk'); }); it('returns item with all optional fields', async () => { mockFindById.mockResolvedValue( makeItem({ expirationDate: new Date('2024-02-01').toISOString(), openedDate: new Date('2024-01-05').toISOString(), preparedDate: new Date('2024-01-06').toISOString(), notes: 'Organic', purchasePrice: 4.99, storeId: 's1', }), ); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/pantry/item-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.expirationDate).toBeDefined(); expect(body.openedDate).toBeDefined(); expect(body.preparedDate).toBeDefined(); expect(body.notes).toBe('Organic'); expect(body.purchasePrice).toBe(4.99); expect(body.storeId).toBe('s1'); }); it('returns 404 when not found', async () => { mockFindById.mockResolvedValue(null); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/pantry/missing', headers: authHeaders, }); expect(res.statusCode).toBe(404); }); }); describe('POST /pantry', () => { it('creates a pantry item', async () => { mockProductFindById.mockResolvedValue({ _id: 'p1', name: 'Milk', category: 'dairy', }); mockFindApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7 }); mockCreate.mockResolvedValue(makeItem()); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/pantry', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ productId: 'p1', storageLocation: 'fridge', quantity: 1, unit: 'piece', }), }); expect(res.statusCode).toBe(201); }); it('rejects missing productId', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/pantry', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ storageLocation: 'fridge', quantity: 1, unit: 'piece', }), }); expect(res.statusCode).toBe(400); }); }); describe('PATCH /pantry/:id', () => { it('updates a pantry item', async () => { mockFindById.mockResolvedValue(makeItem()); mockUpdate.mockResolvedValue(makeItem({ quantity: 3 })); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/pantry/item-1', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ quantity: 3 }), }); expect(res.statusCode).toBe(200); }); }); describe('POST /pantry/:id/transition', () => { it('transitions item status', async () => { const item = makeItem({ status: 'sealed' }); mockFindById.mockResolvedValue(item); mockUpdate.mockResolvedValue({ ...item, status: 'consumed' }); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/pantry/item-1/transition', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'consumed' }), }); expect(res.statusCode).toBe(200); }); }); describe('POST /pantry/batch-transition', () => { it('batch transitions items', async () => { mockFindByIds.mockResolvedValue([makeItem()]); mockBulkUpdateStatus.mockResolvedValue(1); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/pantry/batch-transition', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ itemIds: ['item-1'], status: 'consumed', }), }); expect(res.statusCode).toBe(200); expect(res.json().transitioned).toBe(1); }); }); describe('DELETE /pantry/:id', () => { it('deletes a pantry item', async () => { mockFindById.mockResolvedValue(makeItem()); mockDelete.mockResolvedValue(makeItem()); const res = await app.inject({ method: 'DELETE', url: '/api/v1/households/hh1/pantry/item-1', headers: authHeaders, }); expect(res.statusCode).toBe(204); }); }); });