401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
|
import { RefillsService } from './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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|