import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RegimensService } from './regimens.service.js'; import { DosageFrequency } from '@meshitrack/shared'; describe(RegimensService.name, () => { const mockRegimensRepo = { findByHousehold: vi.fn(), findById: vi.fn(), findActiveByUser: vi.fn(), create: vi.fn(), update: vi.fn(), softDelete: vi.fn(), }; const mockMedicinesRepo = { findById: vi.fn(), findByHousehold: vi.fn(), findDuplicate: vi.fn(), create: vi.fn(), update: vi.fn(), softDelete: 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: RegimensService; beforeEach(() => { vi.clearAllMocks(); service = new RegimensService({ regimensRepository: mockRegimensRepo as never, medicinesRepository: mockMedicinesRepo as never, cabinetRepository: mockCabinetRepo as never, cabinetEventsService: mockCabinetEventsService as never, }); }); describe('list', () => { it('delegates to repository', async () => { const result = { data: [], pagination: { cursor: null, hasMore: false } }; mockRegimensRepo.findByHousehold.mockResolvedValue(result); const response = await service.list('hh1', 'user-1', { limit: 20 }); expect(response).toEqual(result); expect(mockRegimensRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 }); }); }); describe('getById', () => { it('returns regimen when found', async () => { const regimen = { _id: 'reg-1', name: 'Morning' }; mockRegimensRepo.findById.mockResolvedValue(regimen); const result = await service.getById('reg-1', 'hh1', 'user-1'); expect(result).toEqual(regimen); expect(mockRegimensRepo.findById).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1'); }); it('throws NotFoundError when not found', async () => { mockRegimensRepo.findById.mockResolvedValue(null); await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow( 'Regimen not found', ); }); }); describe('create', () => { const createInput = { name: 'Morning Routine', isActive: true, medications: [ { medicineId: 'med-1', dosage: 1, dosageUnit: 'tablet' as const, frequency: DosageFrequency.DAILY, }, ], }; it('creates regimen with denormalized medications', async () => { mockMedicinesRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet', }); const created = { _id: 'reg-1', ...createInput, medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }], }; mockRegimensRepo.create.mockResolvedValue(created); const result = await service.create(createInput, 'hh1', 'user-1'); expect(result).toEqual(created); expect(mockRegimensRepo.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'Morning Routine', isActive: true, medications: expect.arrayContaining([ expect.objectContaining({ medicineId: 'med-1', medicineName: 'Metformin', medicineStrength: 500, medicineStrengthUnit: 'mg', medicineForm: 'tablet', dosage: 1, dosageUnit: 'tablet', frequency: DosageFrequency.DAILY, }), ]), }), 'hh1', 'user-1', 'user-1', ); }); it('preserves optional medication fields', async () => { mockMedicinesRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet', }); mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' }); await service.create( { name: 'Morning', isActive: true, medications: [ { medicineId: 'med-1', dosage: 2, dosageUnit: 'tablet' as const, frequency: DosageFrequency.CUSTOM, customFrequencyPerDay: 4, timeOfDay: 'morning' as never, instructions: 'Take with food', }, ], }, 'hh1', 'user-1', ); expect(mockRegimensRepo.create).toHaveBeenCalledWith( expect.objectContaining({ medications: expect.arrayContaining([ expect.objectContaining({ customFrequencyPerDay: 4, timeOfDay: 'morning', instructions: 'Take with food', }), ]), }), 'hh1', 'user-1', 'user-1', ); }); it('throws NotFoundError when medicine not found', async () => { mockMedicinesRepo.findById.mockResolvedValue(null); await expect(service.create(createInput, 'hh1', 'user-1')).rejects.toThrow( 'Medicine not found: med-1', ); }); it('denormalizes multiple medications', async () => { mockMedicinesRepo.findById .mockResolvedValueOnce({ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet', }) .mockResolvedValueOnce({ _id: 'med-2', name: 'Aspirin', strength: 100, strengthUnit: 'mg', form: 'tablet', }); mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' }); await service.create( { name: 'Full Routine', isActive: true, medications: [ { medicineId: 'med-1', dosage: 1, dosageUnit: 'tablet' as const, frequency: DosageFrequency.DAILY, }, { medicineId: 'med-2', dosage: 1, dosageUnit: 'tablet' as const, frequency: DosageFrequency.TWICE_DAILY, }, ], }, 'hh1', 'user-1', ); expect(mockRegimensRepo.create).toHaveBeenCalledWith( expect.objectContaining({ medications: expect.arrayContaining([ expect.objectContaining({ medicineId: 'med-1', medicineName: 'Metformin' }), expect.objectContaining({ medicineId: 'med-2', medicineName: 'Aspirin' }), ]), }), 'hh1', 'user-1', 'user-1', ); }); }); describe('update', () => { it('updates name only', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' }); const updated = { _id: 'reg-1', name: 'Evening' }; mockRegimensRepo.update.mockResolvedValue(updated); const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' }); expect(result).toEqual(updated); expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { name: 'Evening', }); }); it('updates isActive only', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', isActive: true }); mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1', isActive: false }); await service.update('reg-1', 'hh1', 'user-1', { isActive: false }); expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { isActive: false, }); }); it('updates medications with denormalization', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' }); mockMedicinesRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet', }); mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' }); await service.update('reg-1', 'hh1', 'user-1', { medications: [ { medicineId: 'med-1', dosage: 2, dosageUnit: 'tablet' as const, frequency: DosageFrequency.DAILY, }, ], }); expect(mockRegimensRepo.update).toHaveBeenCalledWith( 'reg-1', 'hh1', 'user-1', expect.objectContaining({ medications: expect.arrayContaining([ expect.objectContaining({ medicineName: 'Metformin' }), ]), }), ); }); it('throws NotFoundError when regimen not found on initial lookup', async () => { mockRegimensRepo.findById.mockResolvedValue(null); await expect( service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' }), ).rejects.toThrow('Regimen not found'); }); it('throws NotFoundError when update returns null', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' }); mockRegimensRepo.update.mockResolvedValue(null); await expect(service.update('reg-1', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow( 'Regimen not found', ); }); it('skips undefined fields in updateData', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' }); mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' }); await service.update('reg-1', 'hh1', 'user-1', {}); expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {}); }); it('throws NotFoundError when medicine in medications not found', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' }); mockMedicinesRepo.findById.mockResolvedValue(null); await expect( service.update('reg-1', 'hh1', 'user-1', { medications: [ { medicineId: 'med-missing', dosage: 1, dosageUnit: 'tablet' as const, frequency: DosageFrequency.DAILY, }, ], }), ).rejects.toThrow('Medicine not found: med-missing'); }); }); describe('delete', () => { it('soft deletes regimen', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' }); mockRegimensRepo.softDelete.mockResolvedValue({ _id: 'reg-1', isDeleted: true }); const result = await service.delete('reg-1', 'hh1', 'user-1'); expect(result.isDeleted).toBe(true); expect(mockRegimensRepo.softDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1'); }); it('throws NotFoundError when regimen not found on initial lookup', async () => { mockRegimensRepo.findById.mockResolvedValue(null); await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow( 'Regimen not found', ); }); it('throws NotFoundError when softDelete returns null', async () => { mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' }); mockRegimensRepo.softDelete.mockResolvedValue(null); await expect(service.delete('reg-1', 'hh1', 'user-1')).rejects.toThrow('Regimen not found'); }); }); describe('getActiveByUser', () => { it('delegates to repository', async () => { const regimens = [{ _id: 'reg-1', isActive: true }]; mockRegimensRepo.findActiveByUser.mockResolvedValue(regimens); const result = await service.getActiveByUser('hh1', 'user-1'); expect(result).toEqual(regimens); expect(mockRegimensRepo.findActiveByUser).toHaveBeenCalledWith('hh1', 'user-1'); }); }); describe('calculateBurnRates', () => { it('returns empty array when no active regimens', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([]); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result).toEqual([]); }); it('returns empty array when regimens have no medications', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([{ _id: 'reg-1', medications: [] }]); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result).toEqual([]); }); it('calculates burn rates for single medicine', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 30, earliestExpiry: new Date('2026-06-01T00:00:00.000Z') }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue( new Map([['med-1', { avgUnitPrice: 0.5, currency: 'USD' }]]), ); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result).toHaveLength(1); expect(result[0].medicineId).toBe('med-1'); expect(result[0].medicineName).toBe('Metformin'); expect(result[0].dailyConsumption).toBe(1); expect(result[0].totalInCabinet).toBe(30); expect(result[0].daysUntilEmpty).toBe(30); expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z'); expect(result[0].avgUnitPrice).toBe(0.5); expect(result[0].projectedDailyCost).toBe(0.5); expect(result[0].projectedMonthlyCost).toBe(15); expect(result[0].projectedYearlyCost).toBe(182.5); expect(result[0].currency).toBe('USD'); }); it('sums daily consumption across multiple regimens', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY, }, ], }, { _id: 'reg-2', medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.TWICE_DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 90, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); // 1*1 + 1*2 = 3 daily expect(result[0].dailyConsumption).toBe(3); expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30 expect(result[0].earliestExpiry).toBeNull(); expect(result[0].avgUnitPrice).toBeNull(); expect(result[0].projectedDailyCost).toBeNull(); expect(result[0].projectedMonthlyCost).toBeNull(); expect(result[0].projectedYearlyCost).toBeNull(); expect(result[0].currency).toBeNull(); }); it('handles medicine not in cabinet stock', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result[0].totalInCabinet).toBe(0); expect(result[0].daysUntilEmpty).toBe(0); // Math.floor(0/1) = 0 }); it('excludes AS_NEEDED frequency from burn rate results', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Ibuprofen', dosage: 1, frequency: DosageFrequency.AS_NEEDED, }, ], }, ]); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result).toEqual([]); }); it('handles CUSTOM frequency with customFrequencyPerDay', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Custom Med', dosage: 2, frequency: DosageFrequency.CUSTOM, customFrequencyPerDay: 3, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 60, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); // 2 * 3 = 6 daily expect(result[0].dailyConsumption).toBe(6); expect(result[0].daysUntilEmpty).toBe(10); // 60 / 6 = 10 }); it('excludes CUSTOM frequency without customFrequencyPerDay (zero consumption)', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Custom Med', dosage: 2, frequency: DosageFrequency.CUSTOM, // no customFrequencyPerDay -> 0 daily consumption -> excluded }, ], }, ]); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result).toEqual([]); }); it('sorts by daysUntilEmpty ascending (most urgent first, nulls last)', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY, }, { medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY, }, { medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.AS_NEEDED, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 30, earliestExpiry: null }, { _id: 'med-2', totalQuantity: 10, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); // AS_NEEDED (med-3) excluded; med-2: 10 days, med-1: 30 days expect(result).toHaveLength(2); expect(result[0].medicineId).toBe('med-2'); expect(result[0].daysUntilEmpty).toBe(10); expect(result[1].medicineId).toBe('med-1'); expect(result[1].daysUntilEmpty).toBe(30); }); it('returns empty array when all medications are AS_NEEDED', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED, }, { medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.AS_NEEDED, }, ], }, ]); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result).toEqual([]); }); it('excludes AS_NEEDED from results even when mixed with scheduled frequencies', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED, }, { medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY, }, { medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-2', totalQuantity: 10, earliestExpiry: null }, { _id: 'med-3', totalQuantity: 30, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); // AS_NEEDED (med-1) excluded; med-2: 10 days, med-3: 30 days expect(result).toHaveLength(2); expect(result[0].medicineId).toBe('med-2'); expect(result[0].daysUntilEmpty).toBe(10); expect(result[1].medicineId).toBe('med-3'); expect(result[1].daysUntilEmpty).toBe(30); }); it('handles WEEKLY frequency', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Weekly Med', dosage: 1, frequency: DosageFrequency.WEEKLY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 4, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); // 1 * (1/7) ~= 0.1429 daily expect(result[0].dailyConsumption).toBeCloseTo(1 / 7); expect(result[0].daysUntilEmpty).toBe(28); // Math.floor(4 / (1/7)) = 28 }); it('handles EVERY_OTHER_DAY frequency', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'EOD Med', dosage: 1, frequency: DosageFrequency.EVERY_OTHER_DAY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 15, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); // 1 * 0.5 = 0.5 daily expect(result[0].dailyConsumption).toBe(0.5); expect(result[0].daysUntilEmpty).toBe(30); // Math.floor(15 / 0.5) = 30 }); it('handles THREE_TIMES_DAILY frequency', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'TID Med', dosage: 1, frequency: DosageFrequency.THREE_TIMES_DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 90, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map()); const result = await service.calculateBurnRates('hh1', 'user-1'); expect(result[0].dailyConsumption).toBe(3); expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30 }); it('calculates projected costs correctly', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Expensive Med', dosage: 2, frequency: DosageFrequency.DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 60, earliestExpiry: null }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue( new Map([['med-1', { avgUnitPrice: 1.5, currency: 'EUR' }]]), ); const result = await service.calculateBurnRates('hh1', 'user-1'); // dailyConsumption = 2, avgUnitPrice = 1.5 expect(result[0].projectedDailyCost).toBe(3); // 1.5 * 2 expect(result[0].projectedMonthlyCost).toBe(90); // 3 * 30 expect(result[0].projectedYearlyCost).toBe(1095); // 3 * 365 expect(result[0].currency).toBe('EUR'); }); it('handles multiple medicines with different stock and price data', async () => { mockRegimensRepo.findActiveByUser.mockResolvedValue([ { _id: 'reg-1', medications: [ { medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY, }, { medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.TWICE_DAILY, }, ], }, ]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([ { _id: 'med-1', totalQuantity: 10, earliestExpiry: new Date('2026-03-01T00:00:00.000Z') }, { _id: 'med-2', totalQuantity: 60, earliestExpiry: new Date('2026-12-01T00:00:00.000Z') }, ]); mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue( new Map([['med-1', { avgUnitPrice: 2.0, currency: 'USD' }]]), ); const result = await service.calculateBurnRates('hh1', 'user-1'); // med-1: 10 days until empty, med-2: 30 days expect(result[0].medicineId).toBe('med-1'); expect(result[0].daysUntilEmpty).toBe(10); expect(result[0].avgUnitPrice).toBe(2.0); expect(result[1].medicineId).toBe('med-2'); expect(result[1].daysUntilEmpty).toBe(30); expect(result[1].avgUnitPrice).toBeNull(); expect(result[1].currency).toBeNull(); }); }); });