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 { DosageUnit, MedicineForm, StrengthUnit, CabinetItemStatus } 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, mockGetAggregateSummary, mockCreate, mockUpdate, mockAdjustQuantity, mockFindExpiringSoon, mockSoftDelete, mockCountByMedicineId, } = vi.hoisted(() => ({ mockFindByHousehold: vi.fn(), mockFindById: vi.fn(), mockGetAggregateSummary: vi.fn(), mockCreate: vi.fn(), mockUpdate: vi.fn(), mockAdjustQuantity: vi.fn(), mockFindExpiringSoon: vi.fn(), mockSoftDelete: vi.fn(), mockCountByMedicineId: vi.fn(), })); vi.mock('./cabinet.repository.js', () => ({ CabinetRepository: class { findByHousehold = mockFindByHousehold; findById = mockFindById; getAggregateSummary = mockGetAggregateSummary; create = mockCreate; update = mockUpdate; adjustQuantity = mockAdjustQuantity; findExpiringSoon = mockFindExpiringSoon; softDelete = mockSoftDelete; countByMedicineId = mockCountByMedicineId; }, })); const { mockMedicineFindById } = vi.hoisted(() => ({ mockMedicineFindById: vi.fn(), })); vi.mock('../medicines/medicines.repository.js', () => ({ MedicinesRepository: class { findById = mockMedicineFindById; findByHousehold = vi.fn(); findDuplicate = vi.fn(); create = vi.fn(); update = vi.fn(); softDelete = vi.fn(); }, })); const { mockProductFindById } = vi.hoisted(() => ({ mockProductFindById: vi.fn(), })); vi.mock('../medicine-products/medicine-products.repository.js', () => ({ MedicineProductsRepository: class { findById = mockProductFindById; findByMedicine = vi.fn(); create = vi.fn(); update = vi.fn(); softDelete = vi.fn(); countByMedicineId = vi.fn(); }, })); vi.mock('../medicine-products/medicine-products.service.js', () => ({ MedicineProductsService: class { listByMedicine = vi.fn(); getById = vi.fn(); create = vi.fn(); update = vi.fn(); delete = vi.fn(); }, })); vi.mock('../medicines/medicines.service.js', () => ({ MedicinesService: class { list = vi.fn(); getById = vi.fn(); create = vi.fn(); update = vi.fn(); delete = 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 medicinesRoutes from '../medicines/medicines.routes.js'; import medicineProductsRoutes from '../medicine-products/medicine-products.routes.js'; import cabinetRoutes from './cabinet.routes.js'; function makeFakeCabinetItem(overrides = {}) { return { _id: 'ci-1', householdId: 'hh1', medicineId: 'med-1', medicineName: 'Metformin', medicineStrength: 500, medicineStrengthUnit: StrengthUnit.MG, medicineForm: MedicineForm.TABLET, quantity: 30, unit: DosageUnit.TABLET, status: CabinetItemStatus.ACTIVE, createdBy: 'kc-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('cabinet.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(medicinesRoutes); await instance.register(medicineProductsRoutes); await instance.register(cabinetRoutes); 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', () => { it('returns paginated list', async () => { const item = makeFakeCabinetItem(); mockFindByHousehold.mockResolvedValue({ data: [item], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet', 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.pagination.hasMore).toBe(false); }); it('handles ObjectId and Date objects in response', async () => { const item = makeFakeCabinetItem({ _id: { toString: () => 'ci-obj' }, createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' }, updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' }, expirationDate: new Date('2026-06-01T00:00:00.000Z'), notes: 'Main supply', medicineProductId: 'prod-1', medicineProductBrand: 'Glucophage', }); mockFindByHousehold.mockResolvedValue({ data: [item], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0]._id).toBe('ci-obj'); expect(body.data[0].expirationDate).toBe('2026-06-01T00:00:00.000Z'); expect(body.data[0].medicineProductBrand).toBe('Glucophage'); }); it('handles string dates in response', async () => { const item = makeFakeCabinetItem({ expirationDate: '2026-12-31T00:00:00.000Z', createdAt: new Date('2024-01-01T00:00:00.000Z'), updatedAt: new Date('2024-01-02T00:00:00.000Z'), }); mockFindByHousehold.mockResolvedValue({ data: [item], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].expirationDate).toBe('2026-12-31T00:00:00.000Z'); expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z'); }); }); describe('GET /api/v1/households/:householdId/cabinet/summary', () => { it('returns aggregate summary', async () => { mockGetAggregateSummary.mockResolvedValue([ { _id: 'med-1', medicineName: 'Metformin', medicineStrength: 500, medicineStrengthUnit: 'mg', medicineForm: 'tablet', totalQuantity: 60, unit: 'tablet', earliestExpiry: new Date('2026-06-01T00:00:00.000Z'), itemCount: 2, }, ]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet/summary', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].totalQuantity).toBe(60); }); }); describe('GET /api/v1/households/:householdId/cabinet/expiring-soon', () => { it('returns items expiring within N days', async () => { mockFindExpiringSoon.mockResolvedValue([makeFakeCabinetItem()]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet/expiring-soon?days=30', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); }); }); describe('GET /api/v1/households/:householdId/cabinet/:id', () => { it('returns a cabinet item', async () => { mockFindById.mockResolvedValue(makeFakeCabinetItem()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/cabinet/ci-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().quantity).toBe(30); }); }); describe('POST /api/v1/households/:householdId/cabinet', () => { it('creates a cabinet item', async () => { mockMedicineFindById.mockResolvedValue({ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet', }); mockCreate.mockResolvedValue(makeFakeCabinetItem()); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/cabinet', headers: authHeaders, payload: { medicineId: 'med-1', quantity: 30, unit: DosageUnit.TABLET, }, }); expect(res.statusCode).toBe(201); expect(res.json().medicineName).toBe('Metformin'); }); }); describe('PATCH /api/v1/households/:householdId/cabinet/:id', () => { it('updates a cabinet item', async () => { mockFindById.mockResolvedValue(makeFakeCabinetItem()); mockUpdate.mockResolvedValue(makeFakeCabinetItem({ quantity: 25 })); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/cabinet/ci-1', headers: authHeaders, payload: { quantity: 25 }, }); expect(res.statusCode).toBe(200); expect(res.json().quantity).toBe(25); }); }); describe('POST /api/v1/households/:householdId/cabinet/:id/adjust', () => { it('adjusts quantity', async () => { mockFindById.mockResolvedValue(makeFakeCabinetItem({ quantity: 30 })); mockAdjustQuantity.mockResolvedValue(makeFakeCabinetItem({ quantity: 27 })); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/cabinet/ci-1/adjust', headers: authHeaders, payload: { delta: -3 }, }); expect(res.statusCode).toBe(200); expect(res.json().quantity).toBe(27); }); }); describe('DELETE /api/v1/households/:householdId/cabinet/:id', () => { it('soft deletes a cabinet item', async () => { mockFindById.mockResolvedValue(makeFakeCabinetItem()); mockSoftDelete.mockResolvedValue(makeFakeCabinetItem({ isDeleted: true })); const res = await app.inject({ method: 'DELETE', url: '/api/v1/households/hh1/cabinet/ci-1', headers: authHeaders, }); expect(res.statusCode).toBe(204); }); }); });