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

500 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CabinetService } from '../../../src/modules/cabinet/cabinet.service.js';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
describe(CabinetService.name, () => {
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 mockMedicinesRepo = {
findById: vi.fn(),
findByHousehold: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockProductsRepo = {
findById: vi.fn(),
findByMedicine: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
};
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: CabinetService;
beforeEach(() => {
vi.clearAllMocks();
service = new CabinetService({
cabinetRepository: mockCabinetRepo as never,
medicinesRepository: mockMedicinesRepo as never,
medicineProductsRepository: mockProductsRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockCabinetRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', { limit: 20 });
expect(response).toEqual(result);
expect(mockCabinetRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
});
});
describe('getById', () => {
it('returns item when found', async () => {
const item = { _id: 'ci-1', quantity: 30 };
mockCabinetRepo.findById.mockResolvedValue(item);
const result = await service.getById('ci-1', 'hh1');
expect(result).toEqual(item);
});
it('throws NotFoundError when not found', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.getById('ci-missing', 'hh1')).rejects.toThrow('Cabinet item not found');
});
});
describe('getSummary', () => {
it('returns aggregated summary with formatted dates', async () => {
const expiryDate = new Date('2026-06-01T00:00:00.000Z');
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 60,
unit: 'tablet',
earliestExpiry: expiryDate,
itemCount: 2,
},
]);
const result = await service.getSummary('hh1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z');
});
it('handles null expiry dates', async () => {
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Test',
medicineStrength: 10,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 30,
unit: 'tablet',
earliestExpiry: null,
itemCount: 1,
},
]);
const result = await service.getSummary('hh1');
expect(result[0].earliestExpiry).toBeNull();
});
});
describe('addItem', () => {
const createInput = {
medicineId: 'med-1',
quantity: 30,
unit: 'tablet' as const,
};
it('creates item with denormalized medicine fields', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
const created = { _id: 'ci-1', ...createInput, medicineName: 'Metformin' };
mockCabinetRepo.create.mockResolvedValue(created);
const result = await service.addItem(createInput, 'hh1', 'user-1');
expect(result).toEqual(created);
expect(mockCabinetRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
}),
'hh1',
'user-1',
);
});
it('logs PURCHASED event after creation', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
await service.addItem(createInput, 'hh1', 'user-1');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
eventType: CabinetEventType.PURCHASED,
quantity: 30,
quantityBefore: 0,
quantityAfter: 30,
sourceType: CabinetEventSourceType.MANUAL,
}),
);
});
it('throws NotFoundError when medicine not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.addItem(createInput, 'hh1', 'user-1')).rejects.toThrow(
'Medicine not found',
);
});
it('denormalizes product brand when medicineProductId given', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockProductsRepo.findById.mockResolvedValue({
_id: 'prod-1',
brand: 'Glucophage',
});
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
await service.addItem({ ...createInput, medicineProductId: 'prod-1' }, 'hh1', 'user-1');
expect(mockCabinetRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ medicineProductBrand: 'Glucophage' }),
'hh1',
'user-1',
);
});
it('throws NotFoundError when product not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockProductsRepo.findById.mockResolvedValue(null);
await expect(
service.addItem({ ...createInput, medicineProductId: 'prod-missing' }, 'hh1', 'user-1'),
).rejects.toThrow('Medicine product not found');
});
});
describe('update', () => {
it('updates and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 25 };
mockCabinetRepo.update.mockResolvedValue(updated);
const result = await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
expect(result).toEqual(updated);
});
it('logs ADJUSTED event when quantity changes', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.ADJUSTED,
quantity: -5,
quantityBefore: 30,
quantityAfter: 25,
}),
);
});
it('does not log event when quantity unchanged', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
expect(mockCabinetEventsService.logEvent).not.toHaveBeenCalled();
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.update('ci-missing', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when update returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue(null);
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('adjustQuantity', () => {
it('adjusts quantity and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 27 };
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
const result = await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1');
expect(result).toEqual(updated);
});
it('logs ADJUSTED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.ADJUSTED,
quantity: -3,
quantityBefore: 30,
quantityAfter: 27,
reason: 'took some',
}),
);
});
it('throws BadRequestError when delta is 0', async () => {
await expect(service.adjustQuantity('ci-1', 'hh1', 0, 'user-1')).rejects.toThrow(
'Delta must be non-zero',
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-missing', 'hh1', 5, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when adjust returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('getExpiringSoon', () => {
it('delegates to repository', async () => {
const items = [{ _id: 'ci-1' }];
mockCabinetRepo.findExpiringSoon.mockResolvedValue(items);
const result = await service.getExpiringSoon('hh1', 30);
expect(result).toEqual(items);
expect(mockCabinetRepo.findExpiringSoon).toHaveBeenCalledWith('hh1', 30);
});
});
describe('delete', () => {
it('soft deletes item and logs DELETED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
const result = await service.delete('ci-1', 'hh1', 'user-1');
expect(result.isDeleted).toBe(true);
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.DELETED,
quantity: -10,
quantityBefore: 10,
quantityAfter: 0,
}),
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 5,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('discard', () => {
it('discards item and logs DISCARDED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 20,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
expect(result.quantity).toBe(0);
expect(result.isDeleted).toBe(true);
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.DISCARDED,
quantity: -20,
quantityBefore: 20,
quantityAfter: 0,
reason: 'expired',
notes: 'smelled off',
}),
);
});
it('throws BadRequestError when quantity is zero', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 0,
medicineId: 'med-1',
medicineName: 'Test',
});
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cannot discard an item with zero quantity',
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.discard('ci-missing', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when discard returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue(null);
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cabinet item not found',
);
});
});
});