434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { PantryService } from '../../../src/modules/pantry/pantry.service.js';
|
|
import { NotFoundError, BadRequestError } from '../../../src/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<string, unknown> = {}) {
|
|
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
|
|
});
|
|
});
|
|
});
|