Tests refactor
This commit is contained in:
parent
245520fb50
commit
99134d8556
165 changed files with 911 additions and 531 deletions
|
|
@ -0,0 +1,158 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { FreshnessCalculatorService } from '../../../src/modules/pantry/freshness-calculator.service.js';
|
||||
import { ItemStatus, FreshnessUrgency, FreshnessSource, StorageLocation } from '@meshitrack/shared';
|
||||
|
||||
describe(FreshnessCalculatorService.name, () => {
|
||||
const service = new FreshnessCalculatorService();
|
||||
|
||||
const baseItem = {
|
||||
status: ItemStatus.SEALED,
|
||||
storageLocation: StorageLocation.FRIDGE,
|
||||
purchaseDate: new Date('2024-01-01'),
|
||||
expirationDate: undefined,
|
||||
openedDate: undefined,
|
||||
preparedDate: undefined,
|
||||
};
|
||||
|
||||
const rule = {
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
freezerLifeDays: 90,
|
||||
};
|
||||
|
||||
describe('calculate', () => {
|
||||
it('uses packaging expiration date when present', () => {
|
||||
const item = { ...baseItem, expirationDate: new Date('2099-12-31') };
|
||||
const result = service.calculate(item, rule);
|
||||
expect(result.source).toBe(FreshnessSource.PACKAGING);
|
||||
expect(result.estimatedExpiryDate).toEqual(new Date('2099-12-31'));
|
||||
});
|
||||
|
||||
it('falls back to 7-day default when no rule provided', () => {
|
||||
const result = service.calculate(baseItem, null);
|
||||
expect(result.source).toBe(FreshnessSource.RULE);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 7);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses freezerLifeDays for freezer storage', () => {
|
||||
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
|
||||
const result = service.calculate(item, rule);
|
||||
expect(result.source).toBe(FreshnessSource.RULE);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 90);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses shelfLifeDays for sealed items in freezer without freezerLifeDays', () => {
|
||||
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
|
||||
const ruleNoFreezer = { shelfLifeDays: 14, openedLifeDays: 7 };
|
||||
const result = service.calculate(item, ruleNoFreezer);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 14);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses openedLifeDays for opened items', () => {
|
||||
const openedDate = new Date('2024-01-05');
|
||||
const item = {
|
||||
...baseItem,
|
||||
status: ItemStatus.OPENED,
|
||||
openedDate,
|
||||
};
|
||||
const result = service.calculate(item, rule);
|
||||
const expected = new Date('2024-01-05');
|
||||
expected.setDate(expected.getDate() + 7);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses openedLifeDays for prepared items with openedDate', () => {
|
||||
const openedDate = new Date('2024-01-05');
|
||||
const item = {
|
||||
...baseItem,
|
||||
status: ItemStatus.PREPARED,
|
||||
openedDate,
|
||||
};
|
||||
const result = service.calculate(item, rule);
|
||||
const expected = new Date('2024-01-05');
|
||||
expected.setDate(expected.getDate() + 7);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses shelfLifeDays for opened item without openedDate', () => {
|
||||
const item = { ...baseItem, status: ItemStatus.OPENED };
|
||||
const result = service.calculate(item, rule);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 14);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses shelfLifeDays for sealed items', () => {
|
||||
const result = service.calculate(baseItem, rule);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 14);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('computes daysRemaining and urgency', () => {
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 10);
|
||||
const item = { ...baseItem, expirationDate: future };
|
||||
const result = service.calculate(item, rule);
|
||||
expect(result.daysRemaining).toBeGreaterThan(5);
|
||||
expect(result.urgency).toBe(FreshnessUrgency.FRESH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('returns true for sealed', () => {
|
||||
expect(service.isActive('sealed')).toBe(true);
|
||||
});
|
||||
it('returns true for opened', () => {
|
||||
expect(service.isActive('opened')).toBe(true);
|
||||
});
|
||||
it('returns true for prepared', () => {
|
||||
expect(service.isActive('prepared')).toBe(true);
|
||||
});
|
||||
it('returns false for consumed', () => {
|
||||
expect(service.isActive('consumed')).toBe(false);
|
||||
});
|
||||
it('returns false for discarded', () => {
|
||||
expect(service.isActive('discarded')).toBe(false);
|
||||
});
|
||||
it('returns false for expired', () => {
|
||||
expect(service.isActive('expired')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapUrgency', () => {
|
||||
it('returns FRESH for > 5 days', () => {
|
||||
expect(service.mapUrgency(6)).toBe(FreshnessUrgency.FRESH);
|
||||
});
|
||||
it('returns USE_SOON for 2-5 days', () => {
|
||||
expect(service.mapUrgency(3)).toBe(FreshnessUrgency.USE_SOON);
|
||||
});
|
||||
it('returns URGENT for 0-1 days', () => {
|
||||
expect(service.mapUrgency(1)).toBe(FreshnessUrgency.URGENT);
|
||||
});
|
||||
it('returns CHECK for -1 to -3 days', () => {
|
||||
expect(service.mapUrgency(-1)).toBe(FreshnessUrgency.CHECK);
|
||||
});
|
||||
it('returns EXPIRED for < -3 days', () => {
|
||||
expect(service.mapUrgency(-4)).toBe(FreshnessUrgency.EXPIRED);
|
||||
});
|
||||
it('returns USE_SOON for exactly 2', () => {
|
||||
expect(service.mapUrgency(2)).toBe(FreshnessUrgency.USE_SOON);
|
||||
});
|
||||
it('returns USE_SOON for exactly 5', () => {
|
||||
expect(service.mapUrgency(5)).toBe(FreshnessUrgency.USE_SOON);
|
||||
});
|
||||
it('returns URGENT for exactly 0', () => {
|
||||
expect(service.mapUrgency(0)).toBe(FreshnessUrgency.URGENT);
|
||||
});
|
||||
it('returns CHECK for exactly -3', () => {
|
||||
expect(service.mapUrgency(-3)).toBe(FreshnessUrgency.CHECK);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
packages/api/tests/modules/pantry/pantry.repository.test.ts
Normal file
254
packages/api/tests/modules/pantry/pantry.repository.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockFind,
|
||||
mockFindOne,
|
||||
mockFindOneAndUpdate,
|
||||
mockFindOneAndDelete,
|
||||
mockSave,
|
||||
mockAggregate,
|
||||
mockUpdateMany,
|
||||
mockFindByIdAndUpdate,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockFindOneAndDelete: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
mockAggregate: vi.fn(),
|
||||
mockUpdateMany: vi.fn(),
|
||||
mockFindByIdAndUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/schemas/pantry-item.schema.js', () => {
|
||||
const chain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
|
||||
const findOneChain = () => ({
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindOne,
|
||||
});
|
||||
|
||||
const updateChain = () => ({
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindOneAndUpdate,
|
||||
});
|
||||
|
||||
const deleteChain = () => ({
|
||||
exec: mockFindOneAndDelete,
|
||||
});
|
||||
|
||||
const updateByIdChain = () => ({
|
||||
exec: mockFindByIdAndUpdate,
|
||||
});
|
||||
|
||||
const updateManyChain = () => ({
|
||||
exec: mockUpdateMany,
|
||||
});
|
||||
|
||||
const aggChain = () => ({
|
||||
exec: mockAggregate,
|
||||
});
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) {
|
||||
this.data = data;
|
||||
}
|
||||
save() {
|
||||
mockSave(this.data);
|
||||
return Promise.resolve({ toObject: () => this.data });
|
||||
}
|
||||
static find = vi.fn(() => chain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
static findOneAndDelete = vi.fn(() => deleteChain());
|
||||
static findByIdAndUpdate = vi.fn(() => updateByIdChain());
|
||||
static updateMany = vi.fn(() => updateManyChain());
|
||||
static aggregate = vi.fn(() => aggChain());
|
||||
}
|
||||
|
||||
return { PantryItemModel: FakeModel };
|
||||
});
|
||||
|
||||
import { PantryRepository } from '../../../src/modules/pantry/pantry.repository.js';
|
||||
|
||||
describe(PantryRepository.name, () => {
|
||||
let repo: PantryRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new PantryRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated results', async () => {
|
||||
const items = [{ _id: { toString: () => 'id1' } }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles hasMore', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({
|
||||
_id: { toString: () => `id${i}` },
|
||||
}));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('applies storageLocation filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { storageLocation: 'fridge', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter with single value', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { status: 'sealed', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter with multiple values', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { status: 'sealed,opened', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies urgency filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { urgency: 'urgent,check', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies single urgency filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { urgency: 'urgent', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies productId filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { productId: 'p1', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies cursor', async () => {
|
||||
const cursor = Buffer.from('abc').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns item', async () => {
|
||||
mockFindOne.mockResolvedValue({ _id: 'id1' });
|
||||
const result = await repo.findById('id1', 'hh1');
|
||||
expect(result).toEqual({ _id: 'id1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExpiringSoon', () => {
|
||||
it('returns items expiring within days', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const result = await repo.findExpiringSoon('hh1', 7);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('supports cursor', async () => {
|
||||
const cursor = Buffer.from('abc').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
const result = await repo.findExpiringSoon('hh1', 7, cursor, 20);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByHousehold', () => {
|
||||
it('returns active items', async () => {
|
||||
mockFind.mockResolvedValue([{ _id: 'id1' }]);
|
||||
const result = await repo.findActiveByHousehold('hh1');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns item', async () => {
|
||||
const data = { name: 'test' };
|
||||
const result = await repo.create(data);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns item', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'id1' });
|
||||
const result = await repo.update('id1', 'hh1', { quantity: 3 });
|
||||
expect(result).toEqual({ _id: 'id1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFreshness', () => {
|
||||
it('updates freshness estimate', async () => {
|
||||
mockFindByIdAndUpdate.mockResolvedValue(undefined);
|
||||
await repo.updateFreshness('id1', { urgency: 'fresh' });
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates freshness and status', async () => {
|
||||
mockFindByIdAndUpdate.mockResolvedValue(undefined);
|
||||
await repo.updateFreshness('id1', { urgency: 'expired' }, 'expired');
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes item', async () => {
|
||||
mockFindOneAndDelete.mockResolvedValue({ _id: 'id1' });
|
||||
await repo.delete('id1', 'hh1');
|
||||
expect(mockFindOneAndDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWasteStats', () => {
|
||||
it('returns aggregation result', async () => {
|
||||
mockAggregate.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]);
|
||||
const result = await repo.getWasteStats('hh1', new Date(), new Date());
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopWastedProducts', () => {
|
||||
it('returns top wasted products', async () => {
|
||||
mockAggregate.mockResolvedValue([{ productId: 'p1', productName: 'Milk', count: 3 }]);
|
||||
const result = await repo.getTopWastedProducts('hh1', new Date(), new Date());
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIds', () => {
|
||||
it('returns items by ids', async () => {
|
||||
mockFind.mockResolvedValue([{ _id: 'id1' }]);
|
||||
const result = await repo.findByIds(['id1'], 'hh1');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdateStatus', () => {
|
||||
it('returns modified count', async () => {
|
||||
mockUpdateMany.mockResolvedValue({ modifiedCount: 2 });
|
||||
const result = await repo.bulkUpdateStatus(['id1', 'id2'], 'hh1', 'consumed' as never);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
393
packages/api/tests/modules/pantry/pantry.routes.test.ts
Normal file
393
packages/api/tests/modules/pantry/pantry.routes.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockFindByHousehold,
|
||||
mockFindById,
|
||||
mockFindExpiringSoon,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockGetWasteStats,
|
||||
mockGetTopWastedProducts,
|
||||
mockFindByIds,
|
||||
mockBulkUpdateStatus,
|
||||
mockFindActiveByHousehold,
|
||||
mockUpdateFreshness,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockFindExpiringSoon: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockGetWasteStats: vi.fn(),
|
||||
mockGetTopWastedProducts: vi.fn(),
|
||||
mockFindByIds: vi.fn(),
|
||||
mockBulkUpdateStatus: vi.fn(),
|
||||
mockFindActiveByHousehold: vi.fn(),
|
||||
mockUpdateFreshness: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockProductFindById } = vi.hoisted(() => ({
|
||||
mockProductFindById: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockFindApplicableRule } = vi.hoisted(() => ({
|
||||
mockFindApplicableRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/pantry/pantry.repository.js', () => ({
|
||||
PantryRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findExpiringSoon = mockFindExpiringSoon;
|
||||
findActiveByHousehold = mockFindActiveByHousehold;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
updateFreshness = mockUpdateFreshness;
|
||||
delete = mockDelete;
|
||||
getWasteStats = mockGetWasteStats;
|
||||
getTopWastedProducts = mockGetTopWastedProducts;
|
||||
findByIds = mockFindByIds;
|
||||
bulkUpdateStatus = mockBulkUpdateStatus;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findById = mockProductFindById;
|
||||
findByIds = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/freshness-rules/freshness-rules.repository.js', () => ({
|
||||
FreshnessRulesRepository: class {
|
||||
findApplicableRule = mockFindApplicableRule;
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
create = vi.fn();
|
||||
update = vi.fn();
|
||||
delete = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import pantryRoutes from '../../../src/modules/pantry/pantry.routes.js';
|
||||
|
||||
const freshness = {
|
||||
estimatedExpiryDate: new Date('2024-02-01').toISOString(),
|
||||
daysRemaining: 14,
|
||||
urgency: 'fresh',
|
||||
source: 'rule',
|
||||
};
|
||||
|
||||
function makeItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'item-1',
|
||||
householdId: 'hh1',
|
||||
productId: 'p1',
|
||||
productName: 'Milk',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
purchaseDate: new Date('2024-01-01').toISOString(),
|
||||
status: 'sealed',
|
||||
freshnessEstimate: freshness,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pantry.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(pantryRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /pantry', () => {
|
||||
it('returns paginated list', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [makeItem()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty list', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pantry/expiring-soon', () => {
|
||||
it('returns expiring items', async () => {
|
||||
mockFindExpiringSoon.mockResolvedValue({
|
||||
data: [makeItem()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/expiring-soon',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pantry/stats', () => {
|
||||
it('returns waste stats', async () => {
|
||||
mockGetWasteStats.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]);
|
||||
mockGetTopWastedProducts.mockResolvedValue([]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/stats',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.totalItemsConsumed).toBe(5);
|
||||
expect(body.wastePercentage).toBeCloseTo(28.57, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pantry/:id', () => {
|
||||
it('returns item', async () => {
|
||||
mockFindById.mockResolvedValue(makeItem());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().productName).toBe('Milk');
|
||||
});
|
||||
|
||||
it('returns item with all optional fields', async () => {
|
||||
mockFindById.mockResolvedValue(
|
||||
makeItem({
|
||||
expirationDate: new Date('2024-02-01').toISOString(),
|
||||
openedDate: new Date('2024-01-05').toISOString(),
|
||||
preparedDate: new Date('2024-01-06').toISOString(),
|
||||
notes: 'Organic',
|
||||
purchasePrice: 4.99,
|
||||
storeId: 's1',
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.expirationDate).toBeDefined();
|
||||
expect(body.openedDate).toBeDefined();
|
||||
expect(body.preparedDate).toBeDefined();
|
||||
expect(body.notes).toBe('Organic');
|
||||
expect(body.purchasePrice).toBe(4.99);
|
||||
expect(body.storeId).toBe('s1');
|
||||
});
|
||||
|
||||
it('returns 404 when not found', async () => {
|
||||
mockFindById.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/missing',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /pantry', () => {
|
||||
it('creates a pantry item', async () => {
|
||||
mockProductFindById.mockResolvedValue({
|
||||
_id: 'p1',
|
||||
name: 'Milk',
|
||||
category: 'dairy',
|
||||
});
|
||||
mockFindApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7 });
|
||||
mockCreate.mockResolvedValue(makeItem());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId: 'p1',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('rejects missing productId', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /pantry/:id', () => {
|
||||
it('updates a pantry item', async () => {
|
||||
mockFindById.mockResolvedValue(makeItem());
|
||||
mockUpdate.mockResolvedValue(makeItem({ quantity: 3 }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ quantity: 3 }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /pantry/:id/transition', () => {
|
||||
it('transitions item status', async () => {
|
||||
const item = makeItem({ status: 'sealed' });
|
||||
mockFindById.mockResolvedValue(item);
|
||||
mockUpdate.mockResolvedValue({ ...item, status: 'consumed' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry/item-1/transition',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'consumed' }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /pantry/batch-transition', () => {
|
||||
it('batch transitions items', async () => {
|
||||
mockFindByIds.mockResolvedValue([makeItem()]);
|
||||
mockBulkUpdateStatus.mockResolvedValue(1);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry/batch-transition',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemIds: ['item-1'],
|
||||
status: 'consumed',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().transitioned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /pantry/:id', () => {
|
||||
it('deletes a pantry item', async () => {
|
||||
mockFindById.mockResolvedValue(makeItem());
|
||||
mockDelete.mockResolvedValue(makeItem());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
434
packages/api/tests/modules/pantry/pantry.service.test.ts
Normal file
434
packages/api/tests/modules/pantry/pantry.service.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue