import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CabinetEventType, CabinetEventSourceType, OrganizerFillStatus, DosageFrequency, } from '@meshitrack/shared'; const { mockSession } = vi.hoisted(() => ({ mockSession: { startTransaction: vi.fn(), commitTransaction: vi.fn(), abortTransaction: vi.fn(), endSession: vi.fn(), }, })); vi.mock('mongoose', () => { return { default: { startSession: vi.fn().mockResolvedValue(mockSession) } }; }); import { OrganizerService } from './organizer.service.js'; describe(OrganizerService.name, () => { const mockOrganizerRepo = { findByHousehold: vi.fn(), findById: vi.fn(), create: vi.fn(), updateStatus: vi.fn(), }; const mockRegimensService = { list: vi.fn(), getById: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), getActiveByUser: vi.fn(), calculateBurnRates: vi.fn(), }; const mockCabinetRepo = { findByHousehold: vi.fn(), findById: vi.fn(), getAggregateSummary: vi.fn(), create: vi.fn(), update: vi.fn(), adjustQuantity: vi.fn(), findExpiringSoon: vi.fn(), softDelete: vi.fn(), countByMedicineId: vi.fn(), discard: vi.fn(), findActiveByMedicineForFEFO: vi.fn(), }; const mockCabinetEventsService = { logEvent: vi.fn(), logEvents: vi.fn(), listEvents: vi.fn(), getEventsByItem: vi.fn(), getSpendingSummary: vi.fn(), getAvgUnitPrices: vi.fn(), }; let service: OrganizerService; beforeEach(() => { vi.clearAllMocks(); service = new OrganizerService({ organizerRepository: mockOrganizerRepo as never, regimensService: mockRegimensService as never, cabinetRepository: mockCabinetRepo as never, cabinetEventsService: mockCabinetEventsService as never, }); }); describe('listFills', () => { it('delegates to repository', async () => { const result = { data: [], pagination: { cursor: null, hasMore: false } }; mockOrganizerRepo.findByHousehold.mockResolvedValue(result); const response = await service.listFills('hh1', 'user-1', { limit: 20 }); expect(response).toEqual(result); expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 }); }); }); describe('getFillById', () => { it('returns fill when found', async () => { const fill = { _id: 'fill-1', regimenName: 'Morning' }; mockOrganizerRepo.findById.mockResolvedValue(fill); const result = await service.getFillById('fill-1', 'hh1'); expect(result).toEqual(fill); }); it('throws NotFoundError when not found', async () => { mockOrganizerRepo.findById.mockResolvedValue(null); await expect(service.getFillById('fill-missing', 'hh1')).rejects.toThrow( 'Organizer fill not found', ); }); }); describe('preview', () => { const makeRegimen = (overrides = {}) => ({ _id: 'reg-1', name: 'Morning Routine', isActive: true, medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY, customFrequencyPerDay: null, }, ], ...overrides, }); it('returns preview with no shortages when stock is sufficient', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); expect(result.regimenName).toBe('Morning Routine'); expect(result.numberOfDays).toBe(7); expect(result.canFillCompletely).toBe(true); expect(result.hasShortages).toBe(false); expect(result.items).toHaveLength(1); expect(result.items[0].medicineId).toBe('med-1'); expect(result.items[0].quantityNeeded).toBe(7); expect(result.items[0].quantityAvailable).toBe(30); expect(result.items[0].isShort).toBe(false); expect(result.items[0].shortage).toBe(0); expect(result.items[0].cabinetBreakdown).toHaveLength(1); expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7); expect(result.items[0].cabinetBreakdown[0].quantityBefore).toBe(30); }); it('returns preview with shortages when stock is insufficient', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); expect(result.canFillCompletely).toBe(false); expect(result.hasShortages).toBe(true); expect(result.items[0].isShort).toBe(true); expect(result.items[0].shortage).toBe(4); expect(result.items[0].quantityAvailable).toBe(3); }); it('handles FEFO allocation across multiple cabinet items', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: new Date('2026-06-01') }, { _id: { toString: () => 'ci-2' }, quantity: 5, expirationDate: new Date('2026-12-01') }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); expect(result.canFillCompletely).toBe(true); expect(result.items[0].cabinetBreakdown).toHaveLength(2); expect(result.items[0].cabinetBreakdown[0].cabinetItemId).toBe('ci-1'); expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(3); expect(result.items[0].cabinetBreakdown[1].cabinetItemId).toBe('ci-2'); expect(result.items[0].cabinetBreakdown[1].quantityToTake).toBe(4); }); it('skips AS_NEEDED medications', async () => { mockRegimensService.getById.mockResolvedValue( makeRegimen({ medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.AS_NEEDED, customFrequencyPerDay: null, }, { medicineId: 'med-2', medicineName: 'Ibuprofen', dosage: 2, frequency: DosageFrequency.DAILY, customFrequencyPerDay: null, }, ], }), ); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-2' }, quantity: 20, expirationDate: null }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); expect(result.items).toHaveLength(1); expect(result.items[0].medicineId).toBe('med-2'); }); it('throws BadRequestError when regimen is not active', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen({ isActive: false })); await expect(service.preview('hh1', 'user-1', 'reg-1', 7)).rejects.toThrow( 'Regimen is not active', ); }); it('handles null expirationDate in cabinet items', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 10, expirationDate: null }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); expect(result.items[0].cabinetBreakdown[0].expirationDate).toBeNull(); }); it('handles empty cabinet (no items available)', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); expect(result.canFillCompletely).toBe(false); expect(result.hasShortages).toBe(true); expect(result.items[0].quantityAvailable).toBe(0); expect(result.items[0].shortage).toBe(7); expect(result.items[0].cabinetBreakdown).toHaveLength(0); }); it('uses customFrequencyPerDay when present', async () => { mockRegimensService.getById.mockResolvedValue( makeRegimen({ medications: [ { medicineId: 'med-1', medicineName: 'Custom Med', dosage: 2, frequency: DosageFrequency.CUSTOM, customFrequencyPerDay: 3, }, ], }), ); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 100, expirationDate: null }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); // dosage(2) * customFrequencyPerDay(3) * numberOfDays(7) = 42 expect(result.items[0].quantityNeeded).toBe(42); }); it('stops taking from cabinet items once remaining is zero', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 7, expirationDate: null }, { _id: { toString: () => 'ci-2' }, quantity: 10, expirationDate: null }, ]); const result = await service.preview('hh1', 'user-1', 'reg-1', 7); // Needs 7, first item has 7 -- second item should not be touched expect(result.items[0].cabinetBreakdown).toHaveLength(1); expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7); }); }); describe('fill', () => { const fillInput = { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'Weekly fill', }; const makeRegimen = () => ({ _id: 'reg-1', name: 'Morning Routine', isActive: true, medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY, customFrequencyPerDay: null, }, ], }); it('executes fill successfully with no shortages', async () => { // preview dependencies mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') }, ]); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockOrganizerRepo.create.mockResolvedValue({ _id: { toString: () => 'fill-1' }, householdId: 'hh1', userId: 'user-1', regimenId: 'reg-1', regimenName: 'Morning Routine', status: OrganizerFillStatus.COMPLETED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); const result = await service.fill('hh1', 'user-1', fillInput); expect(result.status).toBe(OrganizerFillStatus.COMPLETED); expect(mockSession.startTransaction).toHaveBeenCalled(); expect(mockSession.commitTransaction).toHaveBeenCalled(); expect(mockSession.endSession).toHaveBeenCalled(); expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', -7); expect(mockCabinetEventsService.logEvents).toHaveBeenCalled(); }); it('throws BadRequestError when shortages exist and allowPartial is false', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null }, ]); await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow( 'Not enough stock to fill completely', ); }); it('allows partial fill when allowPartial is true', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null }, ]); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 0 }); mockOrganizerRepo.create.mockResolvedValue({ _id: { toString: () => 'fill-1' }, status: OrganizerFillStatus.PARTIAL, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true }); expect(result.status).toBe(OrganizerFillStatus.PARTIAL); expect(mockOrganizerRepo.create).toHaveBeenCalledWith( expect.objectContaining({ status: OrganizerFillStatus.PARTIAL, }), ); }); it('creates CONSUMED events for each deduction', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null }, ]); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockOrganizerRepo.create.mockResolvedValue({ _id: { toString: () => 'fill-1' }, status: OrganizerFillStatus.COMPLETED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); await service.fill('hh1', 'user-1', fillInput); const events = mockCabinetEventsService.logEvents.mock.calls[0][0]; expect(events).toHaveLength(1); expect(events[0]).toEqual( expect.objectContaining({ householdId: 'hh1', userId: 'user-1', cabinetItemId: 'ci-1', medicineId: 'med-1', medicineName: 'Metformin', eventType: CabinetEventType.CONSUMED, quantity: -7, quantityBefore: 30, quantityAfter: 23, sourceType: CabinetEventSourceType.ORGANIZER_FILL, sourceId: 'fill-1', }), ); }); it('handles adjustQuantity returning null (skips deduction)', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null }, ]); mockCabinetRepo.adjustQuantity.mockResolvedValue(null); mockOrganizerRepo.create.mockResolvedValue({ _id: { toString: () => 'fill-1' }, status: OrganizerFillStatus.PARTIAL, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true }); expect(result).toBeTruthy(); // No events logged since adjustQuantity returned null const events = mockCabinetEventsService.logEvents.mock.calls[0][0]; expect(events).toHaveLength(0); }); it('aborts transaction and rethrows on error', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null }, ]); mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure')); await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow('DB failure'); expect(mockSession.abortTransaction).toHaveBeenCalled(); expect(mockSession.endSession).toHaveBeenCalled(); }); it('creates fill with notes when provided', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null }, ]); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockOrganizerRepo.create.mockResolvedValue({ _id: { toString: () => 'fill-1' }, status: OrganizerFillStatus.COMPLETED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); await service.fill('hh1', 'user-1', fillInput); expect(mockOrganizerRepo.create).toHaveBeenCalledWith( expect.objectContaining({ notes: 'Weekly fill', }), ); }); it('sets COMPLETED status when no items have shortages', async () => { mockRegimensService.getById.mockResolvedValue(makeRegimen()); mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([ { _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null }, ]); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockOrganizerRepo.create.mockResolvedValue({ _id: { toString: () => 'fill-1' }, status: OrganizerFillStatus.COMPLETED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); await service.fill('hh1', 'user-1', fillInput); expect(mockOrganizerRepo.create).toHaveBeenCalledWith( expect.objectContaining({ status: OrganizerFillStatus.COMPLETED, }), ); }); }); describe('undoFill', () => { const makeFill = (overrides = {}) => ({ _id: 'fill-1', householdId: 'hh1', userId: 'user-1', status: OrganizerFillStatus.COMPLETED, items: [ { medicineId: 'med-1', medicineName: 'Metformin', quantityNeeded: 7, quantityTaken: 7, wasShort: false, shortage: 0, deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }], }, ], ...overrides, }); it('reverses fill and restores quantities', async () => { mockOrganizerRepo.findById.mockResolvedValue(makeFill()); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 }); mockOrganizerRepo.updateStatus.mockResolvedValue({ ...makeFill(), status: OrganizerFillStatus.REVERSED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); const result = await service.undoFill('hh1', 'fill-1', 'user-1'); expect(result.status).toBe(OrganizerFillStatus.REVERSED); expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', 7); expect(mockOrganizerRepo.updateStatus).toHaveBeenCalledWith( 'fill-1', 'hh1', OrganizerFillStatus.REVERSED, ); expect(mockSession.startTransaction).toHaveBeenCalled(); expect(mockSession.commitTransaction).toHaveBeenCalled(); expect(mockSession.endSession).toHaveBeenCalled(); }); it('creates RESTORED events for each deduction', async () => { mockOrganizerRepo.findById.mockResolvedValue(makeFill()); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 }); mockOrganizerRepo.updateStatus.mockResolvedValue({ ...makeFill(), status: OrganizerFillStatus.REVERSED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); await service.undoFill('hh1', 'fill-1', 'user-1'); const events = mockCabinetEventsService.logEvents.mock.calls[0][0]; expect(events).toHaveLength(1); expect(events[0]).toEqual( expect.objectContaining({ householdId: 'hh1', userId: 'user-1', cabinetItemId: 'ci-1', medicineId: 'med-1', medicineName: 'Metformin', eventType: CabinetEventType.RESTORED, quantity: 7, quantityBefore: 23, quantityAfter: 30, sourceType: CabinetEventSourceType.ORGANIZER_UNDO, sourceId: 'fill-1', }), ); }); it('throws BadRequestError when fill is already reversed', async () => { mockOrganizerRepo.findById.mockResolvedValue( makeFill({ status: OrganizerFillStatus.REVERSED }), ); await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow( 'Fill has already been reversed', ); }); it('throws NotFoundError when fill does not exist', async () => { mockOrganizerRepo.findById.mockResolvedValue(null); await expect(service.undoFill('hh1', 'fill-missing', 'user-1')).rejects.toThrow( 'Organizer fill not found', ); }); it('aborts transaction and rethrows on error', async () => { mockOrganizerRepo.findById.mockResolvedValue(makeFill()); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 }); mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure')); await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow('DB failure'); expect(mockSession.abortTransaction).toHaveBeenCalled(); expect(mockSession.endSession).toHaveBeenCalled(); }); it('handles cabinet item not found when restoring (uses 0 as quantityBefore)', async () => { mockOrganizerRepo.findById.mockResolvedValue(makeFill()); mockCabinetRepo.findById.mockResolvedValue(null); mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 7 }); mockOrganizerRepo.updateStatus.mockResolvedValue({ ...makeFill(), status: OrganizerFillStatus.REVERSED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); await service.undoFill('hh1', 'fill-1', 'user-1'); const events = mockCabinetEventsService.logEvents.mock.calls[0][0]; expect(events[0].quantityBefore).toBe(0); expect(events[0].quantityAfter).toBe(7); }); it('throws NotFoundError when updateStatus returns null', async () => { mockOrganizerRepo.findById.mockResolvedValue(makeFill({ items: [] })); mockOrganizerRepo.updateStatus.mockResolvedValue(null); await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow( 'Organizer fill not found', ); }); it('restores multiple deductions across items', async () => { const fill = makeFill({ items: [ { medicineId: 'med-1', medicineName: 'Metformin', quantityNeeded: 7, quantityTaken: 7, wasShort: false, shortage: 0, deductions: [ { cabinetItemId: 'ci-1', quantityTaken: 4 }, { cabinetItemId: 'ci-2', quantityTaken: 3 }, ], }, { medicineId: 'med-2', medicineName: 'Aspirin', quantityNeeded: 14, quantityTaken: 14, wasShort: false, shortage: 0, deductions: [{ cabinetItemId: 'ci-3', quantityTaken: 14 }], }, ], }); mockOrganizerRepo.findById.mockResolvedValue(fill); mockCabinetRepo.findById.mockResolvedValue({ quantity: 10 }); mockCabinetRepo.adjustQuantity.mockResolvedValue({ quantity: 20 }); mockOrganizerRepo.updateStatus.mockResolvedValue({ ...fill, status: OrganizerFillStatus.REVERSED, }); mockCabinetEventsService.logEvents.mockResolvedValue([]); await service.undoFill('hh1', 'fill-1', 'user-1'); expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledTimes(3); expect(mockCabinetRepo.findById).toHaveBeenCalledTimes(3); const events = mockCabinetEventsService.logEvents.mock.calls[0][0]; expect(events).toHaveLength(3); }); }); });