432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { PurchasesService } from '../../../src/modules/purchases/purchases.service.js';
|
|
|
|
describe(PurchasesService.name, () => {
|
|
const mockPurchasesRepo = {
|
|
create: vi.fn(),
|
|
findByHousehold: vi.fn(),
|
|
findById: vi.fn(),
|
|
update: vi.fn(),
|
|
receiveAll: vi.fn(),
|
|
softDelete: vi.fn(),
|
|
getPendingMedicineStock: vi.fn(),
|
|
};
|
|
const mockCabinetService = {
|
|
addItem: vi.fn(),
|
|
};
|
|
const mockStoresRepo = {
|
|
findById: vi.fn(),
|
|
};
|
|
const mockProductsRepo = {
|
|
findById: vi.fn(),
|
|
};
|
|
const mockPricesRepo = {
|
|
create: vi.fn(),
|
|
};
|
|
|
|
let service: PurchasesService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
service = new PurchasesService({
|
|
purchasesRepository: mockPurchasesRepo as never,
|
|
cabinetService: mockCabinetService as never,
|
|
storesRepository: mockStoresRepo as never,
|
|
medicineProductsRepository: mockProductsRepo as never,
|
|
medicinePricesRepository: mockPricesRepo as never,
|
|
});
|
|
});
|
|
|
|
const fakeStore = { _id: 'st-1', name: 'CVS' };
|
|
const fakeProduct = {
|
|
_id: 'mp-1',
|
|
medicineId: 'med-1',
|
|
medicineName: 'Ibuprofen',
|
|
brand: 'Advil',
|
|
};
|
|
|
|
describe('create', () => {
|
|
const validInput = {
|
|
storeId: 'st-1',
|
|
status: 'in_cabinet' as const,
|
|
items: [{ name: 'Advil', quantity: 30, unit: 'tablet', medicineProductId: 'mp-1' }],
|
|
};
|
|
|
|
it('throws NotFoundError when store not found', async () => {
|
|
mockStoresRepo.findById.mockResolvedValue(null);
|
|
|
|
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow('Store not found');
|
|
});
|
|
|
|
it('throws NotFoundError when medicine product not found', async () => {
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockProductsRepo.findById.mockResolvedValue(null);
|
|
|
|
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
|
'Medicine product not found: mp-1',
|
|
);
|
|
});
|
|
|
|
it('creates purchase with in_cabinet status and adds items to cabinet', async () => {
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
|
mockPurchasesRepo.create.mockResolvedValue(purchase);
|
|
|
|
const result = await service.create(validInput, 'hh1', 'user-1');
|
|
|
|
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
|
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ status: 'in_cabinet', storeName: 'CVS' }),
|
|
);
|
|
expect(result).toEqual(purchase);
|
|
});
|
|
|
|
it('records price when actualPrice is set and status is in_cabinet', async () => {
|
|
const inputWithPrice = {
|
|
...validInput,
|
|
items: [{ ...validInput.items[0], actualPrice: 9.99, currency: 'USD' }],
|
|
};
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockPricesRepo.create.mockResolvedValue({});
|
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
|
|
|
await service.create(inputWithPrice, 'hh1', 'user-1');
|
|
|
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
price: 9.99,
|
|
medicineName: 'Ibuprofen',
|
|
storeName: 'CVS',
|
|
pricePerUnit: expect.closeTo(0.333, 2),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not add to cabinet when status is ordered', async () => {
|
|
const orderedInput = { ...validInput, status: 'ordered' as const };
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1', status: 'ordered' });
|
|
|
|
await service.create(orderedInput, 'hh1', 'user-1');
|
|
|
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
|
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles item without medicineProductId for in_cabinet', async () => {
|
|
const noProductInput = {
|
|
storeId: 'st-1',
|
|
status: 'in_cabinet' as const,
|
|
items: [{ name: 'Generic OTC', quantity: 1, unit: 'tablet' }],
|
|
};
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
|
|
|
await service.create(noProductInput, 'hh1', 'user-1');
|
|
|
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
|
expect(mockPurchasesRepo.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses purchasedAt from input when provided', async () => {
|
|
const inputWithDate = { ...validInput, purchasedAt: '2026-01-15T00:00:00.000Z' };
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
|
|
|
await service.create(inputWithDate, 'hh1', 'user-1');
|
|
|
|
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ purchasedAt: new Date('2026-01-15T00:00:00.000Z') }),
|
|
);
|
|
});
|
|
|
|
it('uses medicineName as brand fallback when brand is undefined', async () => {
|
|
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
|
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockPricesRepo.create.mockResolvedValue({});
|
|
const inputWithPrice = {
|
|
...validInput,
|
|
items: [{ ...validInput.items[0], actualPrice: 5 }],
|
|
};
|
|
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
|
|
|
await service.create(inputWithPrice, 'hh1', 'user-1');
|
|
|
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('receive', () => {
|
|
it('throws NotFoundError when purchase not found', async () => {
|
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
|
|
|
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow(
|
|
'Purchase not found',
|
|
);
|
|
});
|
|
|
|
it('throws BadRequestError when status is not ordered', async () => {
|
|
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet', items: [] });
|
|
|
|
await expect(service.receive('p-1', 'hh1', 'user-1')).rejects.toThrow(
|
|
'Purchase is not in ordered status',
|
|
);
|
|
});
|
|
|
|
it('adds medicine items to cabinet and calls receiveAll', async () => {
|
|
const purchase = {
|
|
_id: 'p-1',
|
|
status: 'ordered',
|
|
storeId: 'st-1',
|
|
storeName: 'CVS',
|
|
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
items: [
|
|
{
|
|
medicineProductId: 'mp-1',
|
|
medicineId: 'med-1',
|
|
name: 'Advil',
|
|
quantity: 30,
|
|
unit: 'tablet',
|
|
addedToCabinet: false,
|
|
},
|
|
],
|
|
};
|
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockPurchasesRepo.receiveAll.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet' });
|
|
|
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
|
|
|
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
|
expect(mockPurchasesRepo.receiveAll).toHaveBeenCalledWith('p-1', 'hh1');
|
|
expect(result.addedCount).toBe(1);
|
|
expect(result.priceRecordsCreated).toBe(0);
|
|
});
|
|
|
|
it('creates price record when actualPrice is set on item', async () => {
|
|
const purchase = {
|
|
_id: 'p-1',
|
|
status: 'ordered',
|
|
storeId: 'st-1',
|
|
storeName: 'CVS',
|
|
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
items: [
|
|
{
|
|
medicineProductId: 'mp-1',
|
|
medicineId: 'med-1',
|
|
name: 'Advil',
|
|
quantity: 30,
|
|
unit: 'tablet',
|
|
actualPrice: 9.99,
|
|
currency: 'USD',
|
|
addedToCabinet: false,
|
|
},
|
|
],
|
|
};
|
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
|
mockPricesRepo.create.mockResolvedValue({});
|
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
|
|
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
|
|
|
expect(mockPricesRepo.create).toHaveBeenCalledOnce();
|
|
expect(result.priceRecordsCreated).toBe(1);
|
|
});
|
|
|
|
it('uses medicineName as brand fallback in price record when brand is undefined', async () => {
|
|
const purchase = {
|
|
_id: 'p-1',
|
|
status: 'ordered',
|
|
storeId: 'st-1',
|
|
storeName: 'CVS',
|
|
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
items: [
|
|
{
|
|
medicineProductId: 'mp-1',
|
|
medicineId: 'med-1',
|
|
name: 'Ibuprofen',
|
|
quantity: 30,
|
|
unit: 'tablet',
|
|
actualPrice: 9.99,
|
|
currency: 'USD',
|
|
addedToCabinet: false,
|
|
},
|
|
],
|
|
};
|
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
|
mockPricesRepo.create.mockResolvedValue({});
|
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
|
|
|
await service.receive('p-1', 'hh1', 'user-1');
|
|
|
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
|
);
|
|
});
|
|
|
|
it('skips price record creation when product not found in receive', async () => {
|
|
const purchase = {
|
|
_id: 'p-1',
|
|
status: 'ordered',
|
|
storeId: 'st-1',
|
|
storeName: 'CVS',
|
|
purchasedAt: new Date(),
|
|
items: [
|
|
{
|
|
medicineProductId: 'mp-1',
|
|
medicineId: 'med-1',
|
|
name: 'Advil',
|
|
quantity: 30,
|
|
unit: 'tablet',
|
|
actualPrice: 9.99,
|
|
addedToCabinet: false,
|
|
},
|
|
],
|
|
};
|
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
|
mockCabinetService.addItem.mockResolvedValue({});
|
|
mockProductsRepo.findById.mockResolvedValue(null);
|
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
|
|
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
|
|
|
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
|
expect(result.priceRecordsCreated).toBe(0);
|
|
});
|
|
|
|
it('skips items already added to cabinet', async () => {
|
|
const purchase = {
|
|
_id: 'p-1',
|
|
status: 'ordered',
|
|
storeId: 'st-1',
|
|
storeName: 'CVS',
|
|
purchasedAt: new Date(),
|
|
items: [
|
|
{
|
|
medicineProductId: 'mp-1',
|
|
medicineId: 'med-1',
|
|
name: 'X',
|
|
quantity: 10,
|
|
unit: 'tablet',
|
|
addedToCabinet: true,
|
|
},
|
|
],
|
|
};
|
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
|
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
|
|
|
const result = await service.receive('p-1', 'hh1', 'user-1');
|
|
|
|
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
|
expect(result.addedCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
it('delegates to repository', async () => {
|
|
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
|
mockPurchasesRepo.findByHousehold.mockResolvedValue(result);
|
|
|
|
const response = await service.list('hh1', { limit: 20 });
|
|
|
|
expect(response).toEqual(result);
|
|
expect(mockPurchasesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
|
});
|
|
});
|
|
|
|
describe('getById', () => {
|
|
it('returns purchase when found', async () => {
|
|
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
|
|
|
expect(await service.getById('p-1', 'hh1')).toEqual(purchase);
|
|
});
|
|
|
|
it('throws NotFoundError when not found', async () => {
|
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
|
|
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Purchase not found');
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('updates and returns purchase', async () => {
|
|
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
|
const updated = { _id: 'p-1', notes: 'updated' };
|
|
mockPurchasesRepo.update.mockResolvedValue(updated);
|
|
|
|
const result = await service.update('p-1', 'hh1', { notes: 'updated' });
|
|
|
|
expect(result).toEqual(updated);
|
|
});
|
|
|
|
it('throws NotFoundError when purchase does not exist', async () => {
|
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
|
|
|
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Purchase not found');
|
|
});
|
|
|
|
it('throws NotFoundError when update returns null', async () => {
|
|
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
|
mockPurchasesRepo.update.mockResolvedValue(null);
|
|
|
|
await expect(service.update('p-1', 'hh1', {})).rejects.toThrow('Purchase not found');
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('soft-deletes and returns purchase', async () => {
|
|
const deleted = { _id: 'p-1', isDeleted: true };
|
|
mockPurchasesRepo.softDelete.mockResolvedValue(deleted);
|
|
|
|
const result = await service.delete('p-1', 'hh1');
|
|
|
|
expect(result).toEqual(deleted);
|
|
expect(mockPurchasesRepo.softDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
|
});
|
|
|
|
it('throws NotFoundError when purchase not found', async () => {
|
|
mockPurchasesRepo.softDelete.mockResolvedValue(null);
|
|
|
|
await expect(service.delete('missing', 'hh1')).rejects.toThrow(
|
|
'Purchase not found or cannot be deleted',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPendingStockByMedicine', () => {
|
|
it('returns map of medicineId to totalUnits', async () => {
|
|
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
|
{ medicineId: 'med-1', totalUnits: 60 },
|
|
{ medicineId: 'med-2', totalUnits: 30 },
|
|
]);
|
|
|
|
const result = await service.getPendingStockByMedicine('hh1');
|
|
|
|
expect(result.get('med-1')).toBe(60);
|
|
expect(result.get('med-2')).toBe(30);
|
|
});
|
|
|
|
it('returns empty map when no pending stock', async () => {
|
|
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
|
|
|
const result = await service.getPendingStockByMedicine('hh1');
|
|
|
|
expect(result.size).toBe(0);
|
|
});
|
|
});
|
|
});
|