Phases 6-7

This commit is contained in:
Aerilyn Weber 2026-05-14 14:47:23 +09:00
parent 76a516a417
commit 029940b079
111 changed files with 17247 additions and 447 deletions

View file

@ -0,0 +1,434 @@
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<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);
});
});
});