MeshiTrack/packages/api/tests/modules/refills/refills.service.test.ts

506 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RefillsService } from '../../../src/modules/refills/refills.service.js';
describe(RefillsService.name, () => {
const mockRepo = {
create: vi.fn(),
findByHousehold: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
updateItem: vi.fn(),
markItemsAddedToCabinet: vi.fn(),
};
const mockRegimensService = {
calculateBurnRates: vi.fn(),
};
const mockCabinetRepo = {
getAggregateSummary: vi.fn(),
};
const mockCabinetService = {
addItem: vi.fn(),
};
const mockPricesRepo = {
getLatestForMedicine: vi.fn(),
compareStores: vi.fn(),
};
const mockPurchasesRepo = {
getPendingMedicineStock: vi.fn(),
};
let service: RefillsService;
beforeEach(() => {
vi.clearAllMocks();
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
service = new RefillsService({
refillsRepository: mockRepo as never,
regimensService: mockRegimensService as never,
cabinetRepository: mockCabinetRepo as never,
cabinetService: mockCabinetService as never,
medicinePricesRepository: mockPricesRepo as never,
purchasesRepository: mockPurchasesRepo as never,
});
});
describe('getAlerts', () => {
it('returns empty array when no medicines are running low', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 1,
totalInCabinet: 100,
daysUntilEmpty: 100,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result).toEqual([]);
});
it('returns alerts for medicines below threshold', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 10,
daysUntilEmpty: 5,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].daysUntilEmpty).toBe(5);
expect(result[0].suggestedQuantity).toBe(60); // ceil(2 * 30)
});
it('attaches lastKnownPrice when available', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
price: 10,
pricePerUnit: 0.1,
storeName: 'Walgreens',
storeId: 'st-1',
date: new Date('2026-01-01T00:00:00.000Z'),
});
mockPricesRepo.compareStores.mockResolvedValue([]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result[0].lastKnownPrice).toBeDefined();
expect(result[0].lastKnownPrice?.storeName).toBe('Walgreens');
});
it('attaches cheapestOption from compareStores', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([
{
storeId: 'st-1',
storeName: 'CVS',
latestPrice: 8,
latestPricePerUnit: 0.08,
currency: 'USD',
date: new Date(),
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result[0].cheapestOption).toBeDefined();
expect(result[0].cheapestOption?.storeName).toBe('CVS');
});
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 4,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
{ medicineId: 'med-1', totalUnits: 60 },
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result[0].pendingOrderStock).toBe(60);
expect(result[0].daysUntilEmptyWithOrders).toBe(32); // (4 + 60) / 2
});
it('excludes medicines with null daysUntilEmpty', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 0,
daysUntilEmpty: null,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result).toHaveLength(0);
});
});
describe('createList', () => {
it('creates list with provided items', async () => {
const list = { _id: 'rl-1', name: 'My List', items: [], status: 'active' };
mockRepo.create.mockResolvedValue(list);
const result = await service.createList(
{
name: 'My List',
fromAlerts: false,
thresholdDays: 7,
items: [
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never },
],
},
'hh1',
'user-1',
);
expect(result).toEqual(list);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'My List', householdId: 'hh1' }),
);
});
it('creates list with no items when neither fromAlerts nor items provided', async () => {
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList(
{ name: 'Empty List', fromAlerts: false, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
);
});
it('computes totalEstimatedCost from items with estimatedPrice', async () => {
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList(
{
name: 'Priced List',
fromAlerts: false,
thresholdDays: 7,
items: [
{
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet' as never,
estimatedPrice: 10,
},
{
medicineId: 'med-2',
medicineName: 'Ibuprofen',
quantity: 20,
unit: 'tablet' as never,
estimatedPrice: 8,
},
],
},
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ totalEstimatedCost: 18 }),
);
});
it('creates list from alerts when fromAlerts is true', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList(
{ name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ medicineId: 'med-1' })]),
}),
);
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', { limit: 20 });
expect(response).toEqual(result);
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
});
});
describe('getById', () => {
it('returns list when found', async () => {
const list = { _id: 'rl-1', name: 'My List' };
mockRepo.findById.mockResolvedValue(list);
expect(await service.getById('rl-1', 'hh1')).toEqual(list);
});
it('throws NotFoundError when not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Refill list not found');
});
});
describe('updateList', () => {
it('updates and returns list', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
const updated = { _id: 'rl-1', name: 'Updated' };
mockRepo.update.mockResolvedValue(updated);
const result = await service.updateList('rl-1', 'hh1', { name: 'Updated' });
expect(result).toEqual(updated);
});
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when update returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
mockRepo.update.mockResolvedValue(null);
await expect(service.updateList('rl-1', 'hh1', {})).rejects.toThrow('Refill list not found');
});
});
describe('updateItem', () => {
it('updates item and returns list', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
mockRepo.updateItem.mockResolvedValue(updated);
const result = await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
expect(result).toEqual(updated);
});
it('sets checkedAt when checked is true', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
mockRepo.updateItem.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
expect(mockRepo.updateItem).toHaveBeenCalledWith(
'rl-1',
'hh1',
'item-1',
expect.objectContaining({ checkedAt: expect.any(Date) }),
);
});
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when item not found', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
mockRepo.updateItem.mockResolvedValue(null);
await expect(service.updateItem('rl-1', 'hh1', 'bad-item', {})).rejects.toThrow(
'Refill list or item not found',
);
});
});
describe('addToCabinet', () => {
it('adds checked items to cabinet', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(result.addedCount).toBe(1);
expect(mockCabinetService.addItem).toHaveBeenCalledTimes(1);
});
it('computes unitPrice when actualPrice is set', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
actualPrice: 9,
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(mockCabinetService.addItem).toHaveBeenCalledWith(
expect.objectContaining({ unitPrice: 0.3, totalPrice: 9 }),
'hh1',
'user-1',
);
});
it('returns zero count when no checked items', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
});
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(result.addedCount).toBe(0);
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
});
it('skips already-added items', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: true,
},
],
});
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(result.addedCount).toBe(0);
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
});
});
describe('getStoreComparison', () => {
it('returns store comparisons for list items', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [{ medicineId: 'med-1' }, { medicineId: 'med-2' }],
});
mockPricesRepo.compareStores
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'CVS' }])
.mockResolvedValueOnce([]);
const result = await service.getStoreComparison('rl-1', 'hh1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
});
it('deduplicates medicine ids', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [{ medicineId: 'med-1' }, { medicineId: 'med-1' }],
});
mockPricesRepo.compareStores.mockResolvedValue([{ storeId: 'st-1', storeName: 'CVS' }]);
const result = await service.getStoreComparison('rl-1', 'hh1');
expect(mockPricesRepo.compareStores).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
});
});
});