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,158 @@
import { describe, it, expect } from 'vitest';
import { FreshnessCalculatorService } from './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);
});
});
});

View file

@ -0,0 +1,89 @@
import { FreshnessUrgency, FreshnessSource, ItemStatus } from '@meshitrack/shared';
import type { StorageLocation } from '@meshitrack/shared';
export interface FreshnessInput {
status: ItemStatus;
storageLocation: StorageLocation;
purchaseDate: Date;
expirationDate?: Date | null;
openedDate?: Date | null;
preparedDate?: Date | null;
}
export interface FreshnessRuleLike {
shelfLifeDays: number;
openedLifeDays: number;
freezerLifeDays?: number | null;
}
export interface FreshnessResult {
estimatedExpiryDate: Date;
daysRemaining: number;
urgency: FreshnessUrgency;
source: FreshnessSource;
}
const ACTIVE_STATUSES = new Set<string>([
ItemStatus.SEALED,
ItemStatus.OPENED,
ItemStatus.PREPARED,
]);
export class FreshnessCalculatorService {
public calculate(item: FreshnessInput, rule: FreshnessRuleLike | null): FreshnessResult {
// If packaging expiration date exists, use it
if (item.expirationDate) {
return this.buildResult(new Date(item.expirationDate), FreshnessSource.PACKAGING);
}
// If no rule, default to 7 days from purchase
if (!rule) {
const fallback = new Date(item.purchaseDate);
fallback.setDate(fallback.getDate() + 7);
return this.buildResult(fallback, FreshnessSource.RULE);
}
// Freezer uses freezerLifeDays from purchase date
if (item.storageLocation === 'freezer' && rule.freezerLifeDays) {
const freezerExpiry = new Date(item.purchaseDate);
freezerExpiry.setDate(freezerExpiry.getDate() + rule.freezerLifeDays);
return this.buildResult(freezerExpiry, FreshnessSource.RULE);
}
// Opened/prepared items use openedLifeDays from openedDate
if (
(item.status === ItemStatus.OPENED || item.status === ItemStatus.PREPARED) &&
item.openedDate
) {
const openedExpiry = new Date(item.openedDate);
openedExpiry.setDate(openedExpiry.getDate() + rule.openedLifeDays);
return this.buildResult(openedExpiry, FreshnessSource.RULE);
}
// Sealed: use shelfLifeDays from purchase date
const sealedExpiry = new Date(item.purchaseDate);
sealedExpiry.setDate(sealedExpiry.getDate() + rule.shelfLifeDays);
return this.buildResult(sealedExpiry, FreshnessSource.RULE);
}
public isActive(status: string): boolean {
return ACTIVE_STATUSES.has(status);
}
public mapUrgency(daysRemaining: number): FreshnessUrgency {
if (daysRemaining > 5) return FreshnessUrgency.FRESH;
if (daysRemaining >= 2) return FreshnessUrgency.USE_SOON;
if (daysRemaining >= 0) return FreshnessUrgency.URGENT;
if (daysRemaining >= -3) return FreshnessUrgency.CHECK;
return FreshnessUrgency.EXPIRED;
}
private buildResult(estimatedExpiryDate: Date, source: FreshnessSource): FreshnessResult {
const now = new Date();
const diffMs = estimatedExpiryDate.getTime() - now.getTime();
const daysRemaining = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const urgency = this.mapUrgency(daysRemaining);
return { estimatedExpiryDate, daysRemaining, urgency, source };
}
}

View 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('../../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 './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);
});
});
});

View file

@ -0,0 +1,198 @@
import { PantryItemModel } from '../../schemas/pantry-item.schema.js';
import type { ItemStatus } from '@meshitrack/shared';
interface FindByHouseholdQuery {
storageLocation?: string;
status?: string;
urgency?: string;
productId?: string;
cursor?: string;
limit: number;
}
export class PantryRepository {
public async findByHousehold(householdId: string, query: FindByHouseholdQuery) {
const filter: Record<string, unknown> = { householdId };
if (query.storageLocation) filter['storageLocation'] = query.storageLocation;
if (query.status) {
const statuses = query.status.split(',').filter(Boolean);
filter['status'] = statuses.length === 1 ? statuses[0] : { $in: statuses };
}
if (query.urgency) {
const urgencies = query.urgency.split(',').filter(Boolean);
filter['freshnessEstimate.urgency'] =
urgencies.length === 1 ? urgencies[0] : { $in: urgencies };
}
if (query.productId) filter['productId'] = query.productId;
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const limit = query.limit;
const items = await PantryItemModel.find(filter)
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
: null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string) {
return PantryItemModel.findOne({ _id: id, householdId }).lean().exec();
}
public async findExpiringSoon(householdId: string, days: number, cursor?: string, limit = 20) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + days);
const filter: Record<string, unknown> = {
householdId,
status: { $in: ['sealed', 'opened', 'prepared'] },
'freshnessEstimate.estimatedExpiryDate': { $lte: cutoff },
'freshnessEstimate.daysRemaining': { $gte: -3 },
};
if (cursor) {
const id = Buffer.from(cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const items = await PantryItemModel.find(filter)
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursorVal =
data.length > 0
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
: null;
return { data, pagination: { cursor: hasMore ? cursorVal : null, hasMore } };
}
public async findActiveByHousehold(householdId: string) {
return PantryItemModel.find({
householdId,
status: { $in: ['sealed', 'opened', 'prepared'] },
})
.lean()
.exec();
}
public async create(data: Record<string, unknown>) {
const doc = new PantryItemModel(data);
const saved = await doc.save();
return saved.toObject();
}
public async update(id: string, householdId: string, data: Record<string, unknown>) {
return PantryItemModel.findOneAndUpdate({ _id: id, householdId }, { $set: data }, { new: true })
.lean()
.exec();
}
public async updateFreshness(
id: string,
freshnessEstimate: Record<string, unknown>,
status?: string,
) {
const update: Record<string, unknown> = { freshnessEstimate };
if (status) update['status'] = status;
return PantryItemModel.findByIdAndUpdate(id, { $set: update }).exec();
}
public async delete(id: string, householdId: string) {
return PantryItemModel.findOneAndDelete({ _id: id, householdId }).exec();
}
public async getWasteStats(householdId: string, start: Date, end: Date) {
return PantryItemModel.aggregate([
{
$match: {
householdId,
status: { $in: ['consumed', 'discarded'] },
updatedAt: { $gte: start, $lte: end },
},
},
{
$group: {
_id: null,
totalConsumed: {
$sum: { $cond: [{ $eq: ['$status', 'consumed'] }, 1, 0] },
},
totalDiscarded: {
$sum: { $cond: [{ $eq: ['$status', 'discarded'] }, 1, 0] },
},
},
},
]).exec();
}
public async getTopWastedProducts(householdId: string, start: Date, end: Date, limit = 5) {
return PantryItemModel.aggregate([
{
$match: {
householdId,
status: 'discarded',
updatedAt: { $gte: start, $lte: end },
},
},
{
$group: {
_id: '$productId',
productName: { $first: '$productName' },
count: { $sum: 1 },
},
},
{ $sort: { count: -1 } },
{ $limit: limit },
{
$project: {
productId: '$_id',
productName: 1,
count: 1,
_id: 0,
},
},
]).exec();
}
public async findByIds(ids: string[], householdId: string) {
return PantryItemModel.find({
_id: { $in: ids },
householdId,
})
.lean()
.exec();
}
public async bulkUpdateStatus(
ids: string[],
householdId: string,
status: ItemStatus,
extra: Record<string, unknown> = {},
) {
const result = await PantryItemModel.updateMany(
{ _id: { $in: ids }, householdId },
{ $set: { status, ...extra } },
).exec();
return result.modifiedCount;
}
}

View 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('./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('../products/products.repository.js', () => ({
ProductsRepository: class {
findById = mockProductFindById;
findByIds = vi.fn();
},
}));
vi.mock('../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('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import pantryRoutes from './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);
});
});
});

View file

@ -0,0 +1,285 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreatePantryItemSchema,
UpdatePantryItemSchema,
TransitionPantryItemSchema,
BatchTransitionSchema,
PantryQuerySchema,
ExpiringQuerySchema,
WasteStatsQuerySchema,
PantryItemResponseSchema,
PantryItemListResponseSchema,
WasteStatsResponseSchema,
BatchTransitionResponseSchema,
type ItemStatus,
type FreshnessUrgency,
type FreshnessSource,
} from '@meshitrack/shared';
import type { StorageLocation } from '@meshitrack/shared';
import { PantryRepository } from './pantry.repository.js';
import { PantryService } from './pantry.service.js';
import { ProductsRepository } from '../products/products.repository.js';
import { FreshnessRulesRepository } from '../freshness-rules/freshness-rules.repository.js';
const HouseholdParams = z.object({ householdId: z.string() });
const ItemParams = z.object({ householdId: z.string(), id: z.string() });
type AnyPantryDoc = {
_id: string | { toString(): string };
householdId: string;
productId: string;
productName: string;
storageLocation: string;
quantity: number;
unit: string;
purchaseDate: string | Date;
expirationDate?: string | Date | null;
openedDate?: string | Date | null;
preparedDate?: string | Date | null;
status: string;
freshnessEstimate: {
estimatedExpiryDate: string | Date;
daysRemaining: number;
urgency: string;
source: string;
};
notes?: string | null;
purchasePrice?: number | null;
storeId?: string | null;
createdBy: string;
createdAt: string | Date;
updatedAt: string | Date;
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
}
function toIso(v: string | Date): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toPantryResponse(doc: AnyPantryDoc): z.infer<typeof PantryItemResponseSchema> {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
productId: doc.productId,
productName: doc.productName,
storageLocation: doc.storageLocation as StorageLocation,
quantity: doc.quantity,
unit: doc.unit,
purchaseDate: toIso(doc.purchaseDate),
...(doc.expirationDate ? { expirationDate: toIso(doc.expirationDate) } : {}),
...(doc.openedDate ? { openedDate: toIso(doc.openedDate) } : {}),
...(doc.preparedDate ? { preparedDate: toIso(doc.preparedDate) } : {}),
status: doc.status as ItemStatus,
freshnessEstimate: {
estimatedExpiryDate: toIso(doc.freshnessEstimate.estimatedExpiryDate),
daysRemaining: doc.freshnessEstimate.daysRemaining,
urgency: doc.freshnessEstimate.urgency as FreshnessUrgency,
source: doc.freshnessEstimate.source as FreshnessSource,
},
...(doc.notes ? { notes: doc.notes } : {}),
...(doc.purchasePrice != null ? { purchasePrice: doc.purchasePrice } : {}),
...(doc.storeId ? { storeId: doc.storeId } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
pantryRepository: PantryRepository;
pantryService: PantryService;
}
}
export default fp(
async (fastify) => {
// Only register if not already registered (products repo may be registered by recipes)
if (!fastify.diContainer.hasRegistration('productsRepository')) {
fastify.diContainer.register({
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
});
}
if (!fastify.diContainer.hasRegistration('freshnessRulesRepository')) {
fastify.diContainer.register({
freshnessRulesRepository: asClass(FreshnessRulesRepository, {
lifetime: Lifetime.SINGLETON,
}),
});
}
fastify.diContainer.register({
pantryRepository: asClass(PantryRepository, { lifetime: Lifetime.SINGLETON }),
pantryService: asClass(PantryService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
// GET /pantry - list pantry items
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/pantry',
schema: {
params: HouseholdParams,
querystring: PantryQuerySchema,
response: { 200: PantryItemListResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const result = await service.list(request.params.householdId, request.query);
const mapped = {
data: result.data.map((d) => toPantryResponse(d as unknown as AnyPantryDoc)),
pagination: result.pagination,
};
return reply.send(mapped);
},
});
// GET /pantry/expiring-soon
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/pantry/expiring-soon',
schema: {
params: HouseholdParams,
querystring: ExpiringQuerySchema,
response: { 200: PantryItemListResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const result = await service.getExpiringSoon(request.params.householdId, request.query);
const mapped = {
data: result.data.map((d) => toPantryResponse(d as unknown as AnyPantryDoc)),
pagination: result.pagination,
};
return reply.send(mapped);
},
});
// GET /pantry/stats
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/pantry/stats',
schema: {
params: HouseholdParams,
querystring: WasteStatsQuerySchema,
response: { 200: WasteStatsResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const result = await service.getWasteStats(request.params.householdId, request.query);
return reply.send(result);
},
});
// GET /pantry/:id
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/pantry/:id',
schema: {
params: ItemParams,
response: { 200: PantryItemResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const item = await service.getById(request.params.id, request.params.householdId);
return reply.send(toPantryResponse(item as unknown as AnyPantryDoc));
},
});
// POST /pantry
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/pantry',
schema: {
params: HouseholdParams,
body: CreatePantryItemSchema,
response: { 201: PantryItemResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const item = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toPantryResponse(item as unknown as AnyPantryDoc));
},
});
// PATCH /pantry/:id
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/pantry/:id',
schema: {
params: ItemParams,
body: UpdatePantryItemSchema,
response: { 200: PantryItemResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const item = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toPantryResponse(item as unknown as AnyPantryDoc));
},
});
// POST /pantry/:id/transition
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/pantry/:id/transition',
schema: {
params: ItemParams,
body: TransitionPantryItemSchema,
response: { 200: PantryItemResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const item = await service.transition(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toPantryResponse(item as unknown as AnyPantryDoc));
},
});
// POST /pantry/batch-transition
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/pantry/batch-transition',
schema: {
params: HouseholdParams,
body: BatchTransitionSchema,
response: { 200: BatchTransitionResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
const result = await service.batchTransition(request.params.householdId, request.body);
return reply.send(result);
},
});
// DELETE /pantry/:id
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/pantry/:id',
schema: {
params: ItemParams,
},
handler: async (request, reply) => {
const service = request.diScope.resolve<PantryService>('pantryService');
await service.delete(request.params.id, request.params.householdId);
return reply.status(204).send();
},
});
},
{ name: 'pantry-routes' },
);

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);
});
});
});

View file

@ -0,0 +1,295 @@
import type { PantryRepository } from './pantry.repository.js';
import type { FreshnessRulesRepository } from '../freshness-rules/freshness-rules.repository.js';
import type { ProductsRepository } from '../products/products.repository.js';
import { FreshnessCalculatorService } from './freshness-calculator.service.js';
import { ItemStatus } from '@meshitrack/shared';
import type {
CreatePantryItemInput,
UpdatePantryItemInput,
TransitionPantryItemInput,
BatchTransitionInput,
PantryQueryInput,
ExpiringQueryInput,
WasteStatsQueryInput,
} from '@meshitrack/shared';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
const VALID_TRANSITIONS: Record<string, string[]> = {
[ItemStatus.SEALED]: [ItemStatus.OPENED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
[ItemStatus.OPENED]: [ItemStatus.PREPARED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
[ItemStatus.PREPARED]: [ItemStatus.CONSUMED, ItemStatus.DISCARDED],
};
interface Deps {
pantryRepository: PantryRepository;
freshnessRulesRepository: FreshnessRulesRepository;
productsRepository: ProductsRepository;
}
export class PantryService {
private readonly pantryRepository: PantryRepository;
private readonly freshnessRulesRepository: FreshnessRulesRepository;
private readonly productsRepository: ProductsRepository;
private readonly freshnessCalculator = new FreshnessCalculatorService();
public constructor({ pantryRepository, freshnessRulesRepository, productsRepository }: Deps) {
this.pantryRepository = pantryRepository;
this.freshnessRulesRepository = freshnessRulesRepository;
this.productsRepository = productsRepository;
}
public async list(householdId: string, query: PantryQueryInput) {
return this.pantryRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const item = await this.pantryRepository.findById(id, householdId);
if (!item) throw new NotFoundError('Pantry item not found');
return item;
}
public async create(data: CreatePantryItemInput, householdId: string, createdBy: string) {
const product = await this.productsRepository.findById(data.productId, householdId);
if (!product) throw new NotFoundError('Product not found');
const purchaseDate = data.purchaseDate ? new Date(data.purchaseDate) : new Date();
const expirationDate = data.expirationDate ? new Date(data.expirationDate) : undefined;
const rule = await this.freshnessRulesRepository.findApplicableRule(
householdId,
(product as Record<string, unknown>).category as string,
data.storageLocation,
);
const freshnessEstimate = this.freshnessCalculator.calculate(
{
status: ItemStatus.SEALED,
storageLocation: data.storageLocation,
purchaseDate,
expirationDate,
},
rule,
);
return this.pantryRepository.create({
householdId,
productId: data.productId,
productName: (product as Record<string, unknown>).name as string,
storageLocation: data.storageLocation,
quantity: data.quantity,
unit: data.unit,
purchaseDate,
...(expirationDate ? { expirationDate } : {}),
status: ItemStatus.SEALED,
freshnessEstimate,
...(data.notes ? { notes: data.notes } : {}),
...(data.purchasePrice != null ? { purchasePrice: data.purchasePrice } : {}),
...(data.storeId ? { storeId: data.storeId } : {}),
createdBy,
});
}
public async update(id: string, householdId: string, data: UpdatePantryItemInput) {
await this.getById(id, householdId);
const updated = await this.pantryRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Pantry item not found');
return updated;
}
public async transition(id: string, householdId: string, data: TransitionPantryItemInput) {
const item = await this.getById(id, householdId);
const currentStatus = (item as Record<string, unknown>).status as string;
const allowed = VALID_TRANSITIONS[currentStatus];
if (!allowed || !allowed.includes(data.status)) {
throw new BadRequestError(`Cannot transition from '${currentStatus}' to '${data.status}'`);
}
const now = data.date ? new Date(data.date) : new Date();
const updates: Record<string, unknown> = { status: data.status };
if (data.status === ItemStatus.OPENED) {
updates['openedDate'] = now;
} else if (data.status === ItemStatus.PREPARED) {
updates['preparedDate'] = now;
}
if (data.notes) updates['notes'] = data.notes;
// Recalculate freshness if transitioning to opened (shelf life changes)
if (data.status === ItemStatus.OPENED) {
const product = await this.productsRepository.findById(
(item as Record<string, unknown>).productId as string,
householdId,
);
const category = product
? ((product as Record<string, unknown>).category as string)
: 'other';
const storageLocation = (item as Record<string, unknown>).storageLocation as string;
const rule = await this.freshnessRulesRepository.findApplicableRule(
householdId,
category,
storageLocation,
);
const freshness = this.freshnessCalculator.calculate(
{
status: ItemStatus.OPENED,
storageLocation: storageLocation as never,
purchaseDate: new Date((item as Record<string, unknown>).purchaseDate as string),
expirationDate: (item as Record<string, unknown>).expirationDate as Date | undefined,
openedDate: now,
},
rule,
);
updates['freshnessEstimate'] = freshness;
}
const updated = await this.pantryRepository.update(id, householdId, updates);
if (!updated) throw new NotFoundError('Pantry item not found');
return updated;
}
public async batchTransition(householdId: string, data: BatchTransitionInput) {
const items = await this.pantryRepository.findByIds(data.itemIds, householdId);
const validIds: string[] = [];
for (const item of items) {
const currentStatus = (item as Record<string, unknown>).status as string;
const allowed = VALID_TRANSITIONS[currentStatus];
if (allowed && allowed.includes(data.status)) {
validIds.push((item as Record<string, unknown>)._id!.toString());
}
}
const extra: Record<string, unknown> = {};
if (data.date) extra['updatedAt'] = new Date(data.date);
if (data.notes) extra['notes'] = data.notes;
const transitioned =
validIds.length > 0
? await this.pantryRepository.bulkUpdateStatus(
validIds,
householdId,
data.status as ItemStatus,
extra,
)
: 0;
return {
transitioned,
failed: data.itemIds.length - transitioned,
};
}
public async getExpiringSoon(householdId: string, query: ExpiringQueryInput) {
return this.pantryRepository.findExpiringSoon(
householdId,
query.days,
query.cursor,
query.limit,
);
}
public async getWasteStats(householdId: string, query: WasteStatsQueryInput) {
const { start, end } = this.getPeriodDates(query.period);
const [stats] = (await this.pantryRepository.getWasteStats(householdId, start, end)) as Record<
string,
number
>[];
const totalConsumed = stats?.totalConsumed ?? 0;
const totalDiscarded = stats?.totalDiscarded ?? 0;
const total = totalConsumed + totalDiscarded;
const wastePercentage = total > 0 ? Math.round((totalDiscarded / total) * 10000) / 100 : 0;
const topWastedProducts = await this.pantryRepository.getTopWastedProducts(
householdId,
start,
end,
);
return {
period: { start: start.toISOString(), end: end.toISOString() },
totalItemsConsumed: totalConsumed,
totalItemsDiscarded: totalDiscarded,
wastePercentage,
topWastedCategories: [],
topWastedProducts: topWastedProducts as {
productId: string;
productName: string;
count: number;
}[],
};
}
public async refreshAllFreshness(householdId: string) {
const items = await this.pantryRepository.findActiveByHousehold(householdId);
for (const item of items) {
const rec = item as Record<string, unknown>;
const product = await this.productsRepository.findById(rec.productId as string, householdId);
const category = product
? ((product as Record<string, unknown>).category as string)
: 'other';
const rule = await this.freshnessRulesRepository.findApplicableRule(
householdId,
category,
rec.storageLocation as string,
);
const freshness = this.freshnessCalculator.calculate(
{
status: rec.status as ItemStatus,
storageLocation: rec.storageLocation as never,
purchaseDate: new Date(rec.purchaseDate as string),
expirationDate: rec.expirationDate as Date | undefined,
openedDate: rec.openedDate as Date | undefined,
},
rule,
);
const newStatus =
freshness.urgency === 'expired' && this.freshnessCalculator.isActive(rec.status as string)
? ItemStatus.EXPIRED
: undefined;
await this.pantryRepository.updateFreshness(
(rec._id as { toString(): string }).toString(),
freshness as unknown as Record<string, unknown>,
newStatus,
);
}
}
public async delete(id: string, householdId: string) {
const item = await this.getById(id, householdId);
await this.pantryRepository.delete(id, householdId);
return item;
}
private getPeriodDates(period: string): { start: Date; end: Date } {
const end = new Date();
const start = new Date();
switch (period) {
case 'week':
start.setDate(end.getDate() - 7);
break;
case 'month':
start.setMonth(end.getMonth() - 1);
break;
case 'quarter':
start.setMonth(end.getMonth() - 3);
break;
case 'year':
start.setFullYear(end.getFullYear() - 1);
break;
}
return { start, end };
}
}