import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PantryService } from './pantry.service.js'; import { NotFoundError, BadRequestError } from '../../common/errors.js'; import { ItemStatus } from '@meshitrack/shared'; const mockPantryRepo = { findByHousehold: vi.fn(), findById: vi.fn(), findExpiringSoon: vi.fn(), findActiveByHousehold: vi.fn(), create: vi.fn(), update: vi.fn(), updateFreshness: vi.fn(), delete: vi.fn(), getWasteStats: vi.fn(), getTopWastedProducts: vi.fn(), findByIds: vi.fn(), bulkUpdateStatus: vi.fn(), }; const mockFreshnessRulesRepo = { findApplicableRule: vi.fn(), }; const mockProductsRepo = { findById: vi.fn(), findByIds: vi.fn(), }; function makeItem(overrides: Record = {}) { return { _id: { toString: () => 'item-1' }, householdId: 'hh1', productId: 'p1', productName: 'Milk', storageLocation: 'fridge', quantity: 1, unit: 'piece', purchaseDate: new Date('2024-01-01').toISOString(), status: ItemStatus.SEALED, freshnessEstimate: { estimatedExpiryDate: new Date('2024-01-15').toISOString(), daysRemaining: 14, urgency: 'fresh', source: 'rule', }, createdBy: 'user-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } function makeProduct() { return { _id: 'p1', householdId: 'hh1', name: 'Milk', category: 'dairy', servingSize: 250, servingUnit: 'ml', nutrition: { calories: 60, protein: 3, carbs: 5, fat: 3 }, }; } describe(PantryService.name, () => { let service: PantryService; beforeEach(() => { vi.clearAllMocks(); service = new PantryService({ pantryRepository: mockPantryRepo as never, freshnessRulesRepository: mockFreshnessRulesRepo as never, productsRepository: mockProductsRepo as never, }); }); describe('list', () => { it('delegates to repository', async () => { const expected = { data: [], pagination: { cursor: null, hasMore: false } }; mockPantryRepo.findByHousehold.mockResolvedValue(expected); const result = await service.list('hh1', { limit: 20 }); expect(result).toEqual(expected); }); }); describe('getById', () => { it('returns item when found', async () => { const item = makeItem(); mockPantryRepo.findById.mockResolvedValue(item); const result = await service.getById('item-1', 'hh1'); expect(result).toEqual(item); }); it('throws NotFoundError when not found', async () => { mockPantryRepo.findById.mockResolvedValue(null); await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError); }); }); describe('create', () => { it('creates a pantry item', async () => { const product = makeProduct(); mockProductsRepo.findById.mockResolvedValue(product); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7, }); mockPantryRepo.create.mockResolvedValue(makeItem()); const result = await service.create( { productId: 'p1', storageLocation: 'fridge' as never, quantity: 1, unit: 'piece' as never, }, 'hh1', 'user-1', ); expect(mockPantryRepo.create).toHaveBeenCalled(); expect(result).toBeDefined(); }); it('creates item with all optional fields', async () => { mockProductsRepo.findById.mockResolvedValue(makeProduct()); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null); mockPantryRepo.create.mockResolvedValue(makeItem()); await service.create( { productId: 'p1', storageLocation: 'fridge' as never, quantity: 2, unit: 'piece' as never, purchaseDate: '2024-01-01T00:00:00Z', expirationDate: '2024-02-01T00:00:00Z', notes: 'Organic', purchasePrice: 4.99, storeId: 's1', }, 'hh1', 'user-1', ); expect(mockPantryRepo.create).toHaveBeenCalled(); }); it('throws NotFoundError when product not found', async () => { mockProductsRepo.findById.mockResolvedValue(null); await expect( service.create( { productId: 'missing', storageLocation: 'fridge' as never, quantity: 1, unit: 'piece' as never, }, 'hh1', 'user-1', ), ).rejects.toThrow(NotFoundError); }); }); describe('update', () => { it('updates and returns item', async () => { const item = makeItem(); mockPantryRepo.findById.mockResolvedValue(item); mockPantryRepo.update.mockResolvedValue({ ...item, quantity: 3 }); const result = await service.update('item-1', 'hh1', { quantity: 3 }); expect((result as Record).quantity).toBe(3); }); it('throws NotFoundError when update returns null', async () => { mockPantryRepo.findById.mockResolvedValue(makeItem()); mockPantryRepo.update.mockResolvedValue(null); await expect(service.update('item-1', 'hh1', { quantity: 3 })).rejects.toThrow(NotFoundError); }); }); describe('transition', () => { it('transitions from sealed to opened', async () => { const item = makeItem({ status: ItemStatus.SEALED }); mockPantryRepo.findById.mockResolvedValue(item); mockProductsRepo.findById.mockResolvedValue(makeProduct()); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7, }); mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED }); const result = await service.transition('item-1', 'hh1', { status: 'opened' as never }); expect((result as Record).status).toBe(ItemStatus.OPENED); }); it('transitions from sealed to consumed', async () => { const item = makeItem({ status: ItemStatus.SEALED }); mockPantryRepo.findById.mockResolvedValue(item); mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED }); const result = await service.transition('item-1', 'hh1', { status: 'consumed' as never }); expect((result as Record).status).toBe(ItemStatus.CONSUMED); }); it('transitions from sealed to discarded', async () => { const item = makeItem({ status: ItemStatus.SEALED }); mockPantryRepo.findById.mockResolvedValue(item); mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.DISCARDED }); const result = await service.transition('item-1', 'hh1', { status: 'discarded' as never }); expect((result as Record).status).toBe(ItemStatus.DISCARDED); }); it('transitions from opened to prepared', async () => { const item = makeItem({ status: ItemStatus.OPENED }); mockPantryRepo.findById.mockResolvedValue(item); mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.PREPARED }); const result = await service.transition('item-1', 'hh1', { status: 'prepared' as never }); expect((result as Record).status).toBe(ItemStatus.PREPARED); }); it('rejects invalid transition', async () => { const item = makeItem({ status: ItemStatus.CONSUMED }); mockPantryRepo.findById.mockResolvedValue(item); await expect( service.transition('item-1', 'hh1', { status: 'opened' as never }), ).rejects.toThrow(BadRequestError); }); it('includes notes and date in transition', async () => { const item = makeItem({ status: ItemStatus.SEALED }); mockPantryRepo.findById.mockResolvedValue(item); mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED }); await service.transition('item-1', 'hh1', { status: 'consumed' as never, date: '2024-01-10T12:00:00Z', notes: 'Used in cooking', }); expect(mockPantryRepo.update).toHaveBeenCalled(); }); it('throws NotFoundError when update returns null', async () => { const item = makeItem({ status: ItemStatus.SEALED }); mockPantryRepo.findById.mockResolvedValue(item); mockPantryRepo.update.mockResolvedValue(null); await expect( service.transition('item-1', 'hh1', { status: 'consumed' as never }), ).rejects.toThrow(NotFoundError); }); it('recalculates freshness when opening and product not found', async () => { const item = makeItem({ status: ItemStatus.SEALED }); mockPantryRepo.findById.mockResolvedValue(item); mockProductsRepo.findById.mockResolvedValue(null); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null); mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED }); await service.transition('item-1', 'hh1', { status: 'opened' as never }); expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith( 'hh1', 'other', 'fridge', ); }); }); describe('batchTransition', () => { it('transitions valid items', async () => { const items = [ makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED }), makeItem({ _id: { toString: () => 'id2' }, status: ItemStatus.OPENED }), ]; mockPantryRepo.findByIds.mockResolvedValue(items); mockPantryRepo.bulkUpdateStatus.mockResolvedValue(2); const result = await service.batchTransition('hh1', { itemIds: ['id1', 'id2'], status: 'consumed' as never, }); expect(result.transitioned).toBe(2); expect(result.failed).toBe(0); }); it('skips items with invalid transitions', async () => { const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.CONSUMED })]; mockPantryRepo.findByIds.mockResolvedValue(items); const result = await service.batchTransition('hh1', { itemIds: ['id1'], status: 'consumed' as never, }); expect(result.transitioned).toBe(0); expect(result.failed).toBe(1); }); it('passes date and notes as extra', async () => { const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED })]; mockPantryRepo.findByIds.mockResolvedValue(items); mockPantryRepo.bulkUpdateStatus.mockResolvedValue(1); await service.batchTransition('hh1', { itemIds: ['id1'], status: 'discarded' as never, date: '2024-01-10T00:00:00Z', notes: 'Expired', }); expect(mockPantryRepo.bulkUpdateStatus).toHaveBeenCalled(); }); }); describe('getExpiringSoon', () => { it('delegates to repository', async () => { const expected = { data: [], pagination: { cursor: null, hasMore: false } }; mockPantryRepo.findExpiringSoon.mockResolvedValue(expected); const result = await service.getExpiringSoon('hh1', { days: 7, limit: 20 }); expect(result).toEqual(expected); }); }); describe('getWasteStats', () => { it('computes waste stats for a period', async () => { mockPantryRepo.getWasteStats.mockResolvedValue([{ totalConsumed: 8, totalDiscarded: 2 }]); mockPantryRepo.getTopWastedProducts.mockResolvedValue([ { productId: 'p1', productName: 'Milk', count: 2 }, ]); const result = await service.getWasteStats('hh1', { period: 'month' }); expect(result.totalItemsConsumed).toBe(8); expect(result.totalItemsDiscarded).toBe(2); expect(result.wastePercentage).toBe(20); expect(result.topWastedProducts).toHaveLength(1); }); it('handles no data', async () => { mockPantryRepo.getWasteStats.mockResolvedValue([]); mockPantryRepo.getTopWastedProducts.mockResolvedValue([]); const result = await service.getWasteStats('hh1', { period: 'week' }); expect(result.totalItemsConsumed).toBe(0); expect(result.totalItemsDiscarded).toBe(0); expect(result.wastePercentage).toBe(0); }); it('handles quarter period', async () => { mockPantryRepo.getWasteStats.mockResolvedValue([]); mockPantryRepo.getTopWastedProducts.mockResolvedValue([]); const result = await service.getWasteStats('hh1', { period: 'quarter' }); expect(result.period.start).toBeDefined(); }); it('handles year period', async () => { mockPantryRepo.getWasteStats.mockResolvedValue([]); mockPantryRepo.getTopWastedProducts.mockResolvedValue([]); const result = await service.getWasteStats('hh1', { period: 'year' }); expect(result.period.start).toBeDefined(); }); }); describe('refreshAllFreshness', () => { it('refreshes all active items', async () => { const item = makeItem(); mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]); mockProductsRepo.findById.mockResolvedValue(makeProduct()); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7, }); mockPantryRepo.updateFreshness.mockResolvedValue(undefined); await service.refreshAllFreshness('hh1'); expect(mockPantryRepo.updateFreshness).toHaveBeenCalledTimes(1); }); it('marks items as expired when urgency is expired', async () => { const item = makeItem({ purchaseDate: new Date('2020-01-01').toISOString(), }); mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]); mockProductsRepo.findById.mockResolvedValue(makeProduct()); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({ shelfLifeDays: 1, openedLifeDays: 1, }); mockPantryRepo.updateFreshness.mockResolvedValue(undefined); await service.refreshAllFreshness('hh1'); const updateCall = mockPantryRepo.updateFreshness.mock.calls[0]; expect(updateCall?.[2]).toBe(ItemStatus.EXPIRED); }); it('handles missing product gracefully', async () => { const item = makeItem(); mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]); mockProductsRepo.findById.mockResolvedValue(null); mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null); mockPantryRepo.updateFreshness.mockResolvedValue(undefined); await service.refreshAllFreshness('hh1'); expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith( 'hh1', 'other', 'fridge', ); }); }); describe('delete', () => { it('deletes item', async () => { mockPantryRepo.findById.mockResolvedValue(makeItem()); mockPantryRepo.delete.mockResolvedValue(makeItem()); const result = await service.delete('item-1', 'hh1'); expect(result).toBeDefined(); }); it('throws NotFoundError when not found', async () => { mockPantryRepo.findById.mockResolvedValue(null); await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError); }); }); });