import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CabinetService } from './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', ); }); }); });