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 { OrganizerFillStatus } 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 { mockListFills, mockGetFillById, mockPreview, mockFill, mockUndoFill } = vi.hoisted(() => ({ mockListFills: vi.fn(), mockGetFillById: vi.fn(), mockPreview: vi.fn(), mockFill: vi.fn(), mockUndoFill: vi.fn(), })); vi.mock('../../../src/modules/organizer/organizer.repository.js', () => ({ OrganizerRepository: class { create = vi.fn(); update = vi.fn(); findById = vi.fn(); findByHousehold = vi.fn(); updateStatus = vi.fn(); }, })); vi.mock('../../../src/modules/organizer/organizer.service.js', () => ({ OrganizerService: class { listFills = mockListFills; getFillById = mockGetFillById; preview = mockPreview; fill = mockFill; undoFill = mockUndoFill; }, })); 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 organizerRoutes from '../../../src/modules/organizer/organizer.routes.js'; function makeFakeFill(overrides = {}) { return { _id: 'fill-1', householdId: 'hh1', userId: 'kc-1', regimenId: 'reg-1', regimenName: 'Daily Medications', numberOfDays: 7, fillDate: '2024-06-01T00:00:00.000Z', items: [ { medicineId: 'med-1', medicineName: 'Metformin', quantityNeeded: 7, quantityTaken: 7, wasShort: false, shortage: 0, deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }], }, ], status: OrganizerFillStatus.COMPLETED, notes: null, createdAt: '2024-06-01T00:00:00.000Z', updatedAt: '2024-06-01T00:00:00.000Z', ...overrides, }; } function makeFakePreview(overrides = {}) { return { regimenName: 'Daily Medications', numberOfDays: 7, items: [ { medicineId: 'med-1', medicineName: 'Metformin', quantityNeeded: 7, quantityAvailable: 30, isShort: false, shortage: 0, cabinetBreakdown: [ { cabinetItemId: 'ci-1', expirationDate: '2025-12-31T00:00:00.000Z', quantityToTake: 7, }, ], }, ], canFillCompletely: true, hasShortages: false, ...overrides, }; } describe('organizer.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(organizerRoutes); 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/organizer/fills', () => { it('returns paginated fill list', async () => { const fill = makeFakeFill(); mockListFills.mockResolvedValue({ data: [fill], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].regimenName).toBe('Daily Medications'); expect(body.data[0].status).toBe(OrganizerFillStatus.COMPLETED); expect(body.pagination.hasMore).toBe(false); }); it('passes query parameters to service', async () => { mockListFills.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills?regimenId=reg-1&status=completed&limit=10', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(mockListFills).toHaveBeenCalledWith( 'hh1', 'kc-1', expect.objectContaining({ regimenId: 'reg-1', status: OrganizerFillStatus.COMPLETED, limit: 10, }), ); }); it('handles ObjectId and Date serialization in fill response', async () => { const fill = makeFakeFill({ _id: { toString: () => 'fill-obj' }, fillDate: new Date('2024-06-01T00:00:00.000Z'), createdAt: { toISOString: () => '2024-06-01T00:00:00.000Z' }, updatedAt: new Date('2024-06-02T00:00:00.000Z'), }); mockListFills.mockResolvedValue({ data: [fill], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0]._id).toBe('fill-obj'); expect(body.data[0].fillDate).toBe('2024-06-01T00:00:00.000Z'); expect(body.data[0].createdAt).toBe('2024-06-01T00:00:00.000Z'); expect(body.data[0].updatedAt).toBe('2024-06-02T00:00:00.000Z'); }); it('omits notes from response when null', async () => { const fill = makeFakeFill({ notes: null }); mockListFills.mockResolvedValue({ data: [fill], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].notes).toBeUndefined(); }); it('includes notes in response when present', async () => { const fill = makeFakeFill({ notes: 'Refilled before holiday' }); mockListFills.mockResolvedValue({ data: [fill], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].notes).toBe('Refilled before holiday'); }); }); describe('GET /api/v1/households/:householdId/organizer/fills/:id', () => { it('returns single fill by id', async () => { const fill = makeFakeFill(); mockGetFillById.mockResolvedValue(fill); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills/fill-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body._id).toBe('fill-1'); expect(body.regimenId).toBe('reg-1'); expect(body.items).toHaveLength(1); expect(body.items[0].deductions).toHaveLength(1); expect(body.items[0].deductions[0].cabinetItemId).toBe('ci-1'); }); it('passes id and householdId to service correctly', async () => { const fill = makeFakeFill(); mockGetFillById.mockResolvedValue(fill); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/organizer/fills/fill-42', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(mockGetFillById).toHaveBeenCalledWith('fill-42', 'hh1'); }); }); describe('POST /api/v1/households/:householdId/organizer/preview', () => { it('returns preview result with items and shortage info', async () => { const preview = makeFakePreview(); mockPreview.mockResolvedValue(preview); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/preview', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { regimenId: 'reg-1', numberOfDays: 7 }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.regimenName).toBe('Daily Medications'); expect(body.canFillCompletely).toBe(true); expect(body.hasShortages).toBe(false); expect(body.items).toHaveLength(1); expect(body.items[0].medicineName).toBe('Metformin'); expect(body.items[0].cabinetBreakdown).toHaveLength(1); }); it('passes regimenId and numberOfDays to service', async () => { const preview = makeFakePreview(); mockPreview.mockResolvedValue(preview); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/preview', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { regimenId: 'reg-99', numberOfDays: 14 }, }); expect(res.statusCode).toBe(200); expect(mockPreview).toHaveBeenCalledWith('hh1', 'kc-1', 'reg-99', 14); }); it('returns preview with hasShortages=true and isShort items', async () => { const preview = makeFakePreview({ canFillCompletely: false, hasShortages: true, items: [ { medicineId: 'med-1', medicineName: 'Metformin', quantityNeeded: 14, quantityAvailable: 5, isShort: true, shortage: 9, cabinetBreakdown: [ { cabinetItemId: 'ci-1', expirationDate: '2025-12-31T00:00:00.000Z', quantityToTake: 5, }, ], }, ], }); mockPreview.mockResolvedValue(preview); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/preview', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { regimenId: 'reg-1', numberOfDays: 14 }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.hasShortages).toBe(true); expect(body.canFillCompletely).toBe(false); expect(body.items[0].isShort).toBe(true); expect(body.items[0].shortage).toBe(9); }); }); describe('POST /api/v1/households/:householdId/organizer/fill', () => { it('creates fill and returns 201 with COMPLETED status', async () => { const fill = makeFakeFill({ status: OrganizerFillStatus.COMPLETED }); mockFill.mockResolvedValue(fill); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/fill', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body._id).toBe('fill-1'); expect(body.status).toBe(OrganizerFillStatus.COMPLETED); }); it('returns 201 with PARTIAL status when wasShort items exist', async () => { const fill = makeFakeFill({ status: OrganizerFillStatus.PARTIAL, items: [ { medicineId: 'med-1', medicineName: 'Metformin', quantityNeeded: 14, quantityTaken: 5, wasShort: true, shortage: 9, deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 5 }], }, ], }); mockFill.mockResolvedValue(fill); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/fill', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { regimenId: 'reg-1', numberOfDays: 14, allowPartial: true }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.status).toBe(OrganizerFillStatus.PARTIAL); expect(body.items[0].wasShort).toBe(true); expect(body.items[0].shortage).toBe(9); }); it('returns 400 on invalid body (missing regimenId)', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/fill', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { numberOfDays: 7, allowPartial: false }, }); expect(res.statusCode).toBe(400); }); it('passes correct args (householdId, userId, body) to service', async () => { const fill = makeFakeFill(); mockFill.mockResolvedValue(fill); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/fill', headers: { ...authHeaders, 'content-type': 'application/json' }, payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' }, }); expect(res.statusCode).toBe(201); expect(mockFill).toHaveBeenCalledWith( 'hh1', 'kc-1', expect.objectContaining({ regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note', }), ); }); }); describe('POST /api/v1/households/:householdId/organizer/fills/:id/undo', () => { it('reverses fill and returns 200 with REVERSED status', async () => { const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED }); mockUndoFill.mockResolvedValue(fill); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/fills/fill-1/undo', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body._id).toBe('fill-1'); expect(body.status).toBe(OrganizerFillStatus.REVERSED); }); it('passes correct args to service (householdId first, then id, then userId)', async () => { const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED }); mockUndoFill.mockResolvedValue(fill); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/organizer/fills/fill-42/undo', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(mockUndoFill).toHaveBeenCalledWith('hh1', 'fill-42', 'kc-1'); }); }); });