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 { mockListEvents, mockGetEventsByItem, mockGetSpendingSummary } = vi.hoisted(() => ({ mockListEvents: vi.fn(), mockGetEventsByItem: vi.fn(), mockGetSpendingSummary: vi.fn(), })); vi.mock('./cabinet-events.repository.js', () => ({ CabinetEventsRepository: class { create = vi.fn(); createMany = vi.fn(); findByHousehold = vi.fn(); findByCabinetItem = vi.fn(); getSpendingSummary = vi.fn(); getAvgUnitPriceByMedicine = vi.fn(); }, })); vi.mock('./cabinet-events.service.js', () => ({ CabinetEventsService: class { logEvent = vi.fn(); logEvents = vi.fn(); listEvents = mockListEvents; getEventsByItem = mockGetEventsByItem; getSpendingSummary = mockGetSpendingSummary; getAvgUnitPrices = 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 cabinetEventsRoutes from './cabinet-events.routes.js'; function makeFakeEvent(overrides = {}) { return { _id: 'ev-1', householdId: 'hh1', userId: 'kc-1', cabinetItemId: 'ci-1', medicineId: 'med-1', medicineName: 'Metformin', eventType: 'purchased', quantity: 10, quantityBefore: 0, quantityAfter: 10, sourceType: 'manual', createdAt: '2024-06-01T00:00:00.000Z', ...overrides, }; } describe('cabinet-events.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(cabinetEventsRoutes); 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/cabinet-events', () => { it('returns paginated event list', async () => { const event = makeFakeEvent(); mockListEvents.mockResolvedValue({ data: [event], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].medicineName).toBe('Metformin'); expect(body.data[0].eventType).toBe('purchased'); expect(body.pagination.hasMore).toBe(false); }); it('handles ObjectId and Date objects in response', async () => { const event = makeFakeEvent({ _id: { toString: () => 'ev-obj' }, createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' }, unitPrice: 5.5, totalPrice: 55, currency: 'USD', storeId: 'store-1', storeName: 'Pharmacy A', sourceId: 'src-1', reason: 'restocking', notes: 'bulk purchase', }); mockListEvents.mockResolvedValue({ data: [event], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0]._id).toBe('ev-obj'); expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z'); expect(body.data[0].unitPrice).toBe(5.5); expect(body.data[0].totalPrice).toBe(55); expect(body.data[0].currency).toBe('USD'); expect(body.data[0].storeId).toBe('store-1'); expect(body.data[0].storeName).toBe('Pharmacy A'); expect(body.data[0].sourceId).toBe('src-1'); expect(body.data[0].reason).toBe('restocking'); expect(body.data[0].notes).toBe('bulk purchase'); }); it('handles Date instances in createdAt', async () => { const event = makeFakeEvent({ createdAt: new Date('2024-03-15T12:00:00.000Z'), }); mockListEvents.mockResolvedValue({ data: [event], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].createdAt).toBe('2024-03-15T12:00:00.000Z'); }); it('omits null optional fields from response', async () => { const event = makeFakeEvent({ unitPrice: null, totalPrice: null, currency: null, storeId: null, storeName: null, sourceId: null, reason: null, notes: null, }); mockListEvents.mockResolvedValue({ data: [event], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].unitPrice).toBeUndefined(); expect(body.data[0].totalPrice).toBeUndefined(); expect(body.data[0].currency).toBeUndefined(); expect(body.data[0].storeId).toBeUndefined(); expect(body.data[0].storeName).toBeUndefined(); expect(body.data[0].sourceId).toBeUndefined(); expect(body.data[0].reason).toBeUndefined(); expect(body.data[0].notes).toBeUndefined(); }); it('passes query parameters to service', async () => { mockListEvents.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events?medicineId=med-1&eventType=purchased&limit=10', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(mockListEvents).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ medicineId: 'med-1', eventType: 'purchased', limit: 10, }), ); }); }); describe('GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId', () => { it('returns paginated events for a cabinet item', async () => { const event = makeFakeEvent(); mockGetEventsByItem.mockResolvedValue({ data: [event], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].cabinetItemId).toBe('ci-1'); expect(body.pagination.hasMore).toBe(false); }); it('passes query parameters to service', async () => { mockGetEventsByItem.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1?limit=5&cursor=abc', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(mockGetEventsByItem).toHaveBeenCalledWith( 'hh1', 'ci-1', expect.objectContaining({ limit: 5, cursor: 'abc', }), ); }); it('handles ObjectId and Date objects in by-item response', async () => { const event = makeFakeEvent({ _id: { toString: () => 'ev-obj-2' }, createdAt: new Date('2024-05-01T00:00:00.000Z'), }); mockGetEventsByItem.mockResolvedValue({ data: [event], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0]._id).toBe('ev-obj-2'); expect(body.data[0].createdAt).toBe('2024-05-01T00:00:00.000Z'); }); }); describe('GET /api/v1/households/:householdId/cabinet-events/spending-summary', () => { it('returns spending summary', async () => { mockGetSpendingSummary.mockResolvedValue({ totalSpent: 250, currency: 'USD', byMedicine: [ { medicineId: 'med-1', medicineName: 'Metformin', totalSpent: 250, totalQuantity: 25, avgUnitPrice: 10, purchaseCount: 5, }, ], byPeriod: [ { period: '2024-01', totalSpent: 100 }, { period: '2024-02', totalSpent: 150 }, ], }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events/spending-summary', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.totalSpent).toBe(250); expect(body.currency).toBe('USD'); expect(body.byMedicine).toHaveLength(1); expect(body.byMedicine[0].medicineId).toBe('med-1'); expect(body.byPeriod).toHaveLength(2); }); it('passes query parameters to service', async () => { mockGetSpendingSummary.mockResolvedValue({ totalSpent: 0, currency: null, byMedicine: [], byPeriod: [], }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events/spending-summary?period=quarter&medicineId=med-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(mockGetSpendingSummary).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ period: 'quarter', medicineId: 'med-1', }), ); }); it('returns empty summary with null currency', async () => { mockGetSpendingSummary.mockResolvedValue({ totalSpent: 0, currency: null, byMedicine: [], byPeriod: [], }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet-events/spending-summary', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.totalSpent).toBe(0); expect(body.currency).toBeNull(); expect(body.byMedicine).toHaveLength(0); expect(body.byPeriod).toHaveLength(0); }); }); });