Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
|
|
@ -1,434 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue