Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockFind,
|
||||
mockFindOne,
|
||||
mockFindOneAndUpdate,
|
||||
mockFindOneAndDelete,
|
||||
mockSave,
|
||||
mockFindById,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockFindOneAndDelete: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/freshness-rule.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 findByIdChain = () => ({
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindById,
|
||||
});
|
||||
|
||||
const updateChain = () => ({
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindOneAndUpdate,
|
||||
});
|
||||
|
||||
const deleteChain = () => ({
|
||||
exec: mockFindOneAndDelete,
|
||||
});
|
||||
|
||||
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 findById = vi.fn(() => findByIdChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
static findOneAndDelete = vi.fn(() => deleteChain());
|
||||
}
|
||||
|
||||
return { FreshnessRuleModel: FakeModel };
|
||||
});
|
||||
|
||||
import { FreshnessRulesRepository } from './freshness-rules.repository.js';
|
||||
|
||||
describe(FreshnessRulesRepository.name, () => {
|
||||
let repo: FreshnessRulesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new FreshnessRulesRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated results', async () => {
|
||||
mockFind.mockResolvedValue([{ _id: { toString: () => 'id1' } }]);
|
||||
const result = await repo.findByHousehold('hh1', { limit: 50 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('applies category filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { category: 'dairy', limit: 50 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies storageLocation filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { storageLocation: 'fridge', limit: 50 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies cursor', async () => {
|
||||
const cursor = Buffer.from('abc').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { cursor, limit: 50 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns rule', async () => {
|
||||
mockFindById.mockResolvedValue({ _id: 'id1' });
|
||||
const result = await repo.findById('id1');
|
||||
expect(result).toEqual({ _id: 'id1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findApplicableRule', () => {
|
||||
it('returns household rule when available', async () => {
|
||||
const rule = { _id: 'r1', householdId: 'hh1' };
|
||||
mockFindOne.mockResolvedValueOnce(rule);
|
||||
const result = await repo.findApplicableRule('hh1', 'dairy', 'fridge');
|
||||
expect(result).toEqual(rule);
|
||||
});
|
||||
|
||||
it('falls back to system rule', async () => {
|
||||
const systemRule = { _id: 'r2', householdId: null };
|
||||
mockFindOne.mockResolvedValueOnce(null).mockResolvedValueOnce(systemRule);
|
||||
const result = await repo.findApplicableRule('hh1', 'dairy', 'fridge');
|
||||
expect(result).toEqual(systemRule);
|
||||
});
|
||||
|
||||
it('returns null when no rule found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
const result = await repo.findApplicableRule('hh1', 'dairy', 'fridge');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns rule', async () => {
|
||||
const data = {
|
||||
category: 'dairy',
|
||||
storageLocation: 'fridge',
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
};
|
||||
const result = await repo.create(data);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns rule', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'id1' });
|
||||
const result = await repo.update('id1', 'hh1', { shelfLifeDays: 10 });
|
||||
expect(result).toEqual({ _id: 'id1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes rule', async () => {
|
||||
mockFindOneAndDelete.mockResolvedValue({ _id: 'id1' });
|
||||
await repo.delete('id1', 'hh1');
|
||||
expect(mockFindOneAndDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { FreshnessRuleModel } from '../../schemas/freshness-rule.schema.js';
|
||||
|
||||
interface FindQuery {
|
||||
category?: string;
|
||||
storageLocation?: string;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export class FreshnessRulesRepository {
|
||||
public async findByHousehold(householdId: string, query: FindQuery) {
|
||||
const filter: Record<string, unknown> = {
|
||||
$or: [{ householdId }, { householdId: { $exists: false } }, { householdId: null }],
|
||||
};
|
||||
|
||||
if (query.category) filter['category'] = query.category;
|
||||
if (query.storageLocation) filter['storageLocation'] = query.storageLocation;
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await FreshnessRuleModel.find(filter)
|
||||
.sort({ category: 1, storageLocation: 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) {
|
||||
return FreshnessRuleModel.findById(id).lean().exec();
|
||||
}
|
||||
|
||||
public async findApplicableRule(householdId: string, category: string, storageLocation: string) {
|
||||
// Household override takes priority
|
||||
const householdRule = await FreshnessRuleModel.findOne({
|
||||
householdId,
|
||||
category,
|
||||
storageLocation,
|
||||
})
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
if (householdRule) return householdRule;
|
||||
|
||||
// Fall back to system default
|
||||
return FreshnessRuleModel.findOne({
|
||||
$or: [{ householdId: null }, { householdId: { $exists: false } }],
|
||||
category,
|
||||
storageLocation,
|
||||
})
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async create(data: Record<string, unknown>) {
|
||||
const doc = new FreshnessRuleModel(data);
|
||||
const saved = await doc.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: Record<string, unknown>) {
|
||||
return FreshnessRuleModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
return FreshnessRuleModel.findOneAndDelete({ _id: id, householdId }).exec();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
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, mockCreate, mockUpdate, mockDelete } = vi.hoisted(
|
||||
() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('./freshness-rules.repository.js', () => ({
|
||||
FreshnessRulesRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findApplicableRule = vi.fn();
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
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 freshnessRulesRoutes from './freshness-rules.routes.js';
|
||||
|
||||
function makeRule(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'rule-1',
|
||||
householdId: 'hh1',
|
||||
category: 'dairy',
|
||||
storageLocation: 'fridge',
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
spoilageSignsToCheck: ['smell'],
|
||||
source: 'household',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('freshness-rules.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(freshnessRulesRoutes);
|
||||
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 /freshness-rules', () => {
|
||||
it('returns paginated list', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [makeRule()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/freshness-rules',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /freshness-rules', () => {
|
||||
it('creates a rule', async () => {
|
||||
mockCreate.mockResolvedValue(makeRule());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/freshness-rules',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category: 'dairy',
|
||||
storageLocation: 'fridge',
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('rejects invalid category', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/freshness-rules',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category: 'invalid',
|
||||
storageLocation: 'fridge',
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /freshness-rules/:id', () => {
|
||||
it('updates a rule', async () => {
|
||||
mockFindById.mockResolvedValue(makeRule());
|
||||
mockUpdate.mockResolvedValue(makeRule({ shelfLifeDays: 10 }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/freshness-rules/rule-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ shelfLifeDays: 10 }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /freshness-rules/:id with optional fields', () => {
|
||||
it('returns rule with all optional fields', async () => {
|
||||
mockFindById.mockResolvedValue(makeRule());
|
||||
mockUpdate.mockResolvedValue(makeRule({ freezerLifeDays: 90, tips: 'Keep sealed' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/freshness-rules/rule-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ freezerLifeDays: 90, tips: 'Keep sealed' }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.freezerLifeDays).toBe(90);
|
||||
expect(body.tips).toBe('Keep sealed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /freshness-rules/:id', () => {
|
||||
it('deletes a rule', async () => {
|
||||
mockFindById.mockResolvedValue(makeRule());
|
||||
mockDelete.mockResolvedValue(makeRule());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/freshness-rules/rule-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateFreshnessRuleSchema,
|
||||
UpdateFreshnessRuleSchema,
|
||||
FreshnessRuleQuerySchema,
|
||||
FreshnessRuleResponseSchema,
|
||||
FreshnessRuleListResponseSchema,
|
||||
type FreshnessRuleSource,
|
||||
type StorageLocation,
|
||||
type ProductCategory,
|
||||
} from '@meshitrack/shared';
|
||||
import { FreshnessRulesRepository } from './freshness-rules.repository.js';
|
||||
import { FreshnessRulesService } from './freshness-rules.service.js';
|
||||
|
||||
const HouseholdParams = z.object({ householdId: z.string() });
|
||||
const RuleParams = z.object({ householdId: z.string(), id: z.string() });
|
||||
|
||||
type AnyRuleDoc = {
|
||||
_id: string | { toString(): string };
|
||||
householdId?: string | null;
|
||||
category: string;
|
||||
storageLocation: string;
|
||||
shelfLifeDays: number;
|
||||
openedLifeDays: number;
|
||||
freezerLifeDays?: number | null;
|
||||
spoilageSignsToCheck: string[];
|
||||
tips?: string | null;
|
||||
source: 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 toRuleResponse(doc: AnyRuleDoc): z.infer<typeof FreshnessRuleResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
...(doc.householdId ? { householdId: doc.householdId } : {}),
|
||||
category: doc.category as ProductCategory,
|
||||
storageLocation: doc.storageLocation as StorageLocation,
|
||||
shelfLifeDays: doc.shelfLifeDays,
|
||||
openedLifeDays: doc.openedLifeDays,
|
||||
...(doc.freezerLifeDays != null ? { freezerLifeDays: doc.freezerLifeDays } : {}),
|
||||
spoilageSignsToCheck: doc.spoilageSignsToCheck,
|
||||
...(doc.tips ? { tips: doc.tips } : {}),
|
||||
source: doc.source as FreshnessRuleSource,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
freshnessRulesRepository: FreshnessRulesRepository;
|
||||
freshnessRulesService: FreshnessRulesService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
if (!fastify.diContainer.hasRegistration('freshnessRulesRepository')) {
|
||||
fastify.diContainer.register({
|
||||
freshnessRulesRepository: asClass(FreshnessRulesRepository, {
|
||||
lifetime: Lifetime.SINGLETON,
|
||||
}),
|
||||
});
|
||||
}
|
||||
fastify.diContainer.register({
|
||||
freshnessRulesService: asClass(FreshnessRulesService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
// GET /freshness-rules
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/freshness-rules',
|
||||
schema: {
|
||||
params: HouseholdParams,
|
||||
querystring: FreshnessRuleQuerySchema,
|
||||
response: { 200: FreshnessRuleListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
const mapped = {
|
||||
data: result.data.map((d) => toRuleResponse(d as unknown as AnyRuleDoc)),
|
||||
pagination: result.pagination,
|
||||
};
|
||||
return reply.send(mapped);
|
||||
},
|
||||
});
|
||||
|
||||
// POST /freshness-rules
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/freshness-rules',
|
||||
schema: {
|
||||
params: HouseholdParams,
|
||||
body: CreateFreshnessRuleSchema,
|
||||
response: { 201: FreshnessRuleResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||
const rule = await service.create(request.body, request.params.householdId);
|
||||
return reply.status(201).send(toRuleResponse(rule as unknown as AnyRuleDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /freshness-rules/:id
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/freshness-rules/:id',
|
||||
schema: {
|
||||
params: RuleParams,
|
||||
body: UpdateFreshnessRuleSchema,
|
||||
response: { 200: FreshnessRuleResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||
const rule = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toRuleResponse(rule as unknown as AnyRuleDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /freshness-rules/:id
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/freshness-rules/:id',
|
||||
schema: {
|
||||
params: RuleParams,
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
},
|
||||
{ name: 'freshness-rules-routes' },
|
||||
);
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FreshnessRulesService } from './freshness-rules.service.js';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
import { FreshnessRuleSource } from '@meshitrack/shared';
|
||||
|
||||
const mockRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findApplicableRule: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
describe(FreshnessRulesService.name, () => {
|
||||
let service: FreshnessRulesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new FreshnessRulesService({
|
||||
freshnessRulesRepository: mockRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRepo.findByHousehold.mockResolvedValue(expected);
|
||||
const result = await service.list('hh1', { limit: 50 });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates household rule', async () => {
|
||||
const data = {
|
||||
category: 'dairy' as never,
|
||||
storageLocation: 'fridge' as never,
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
spoilageSignsToCheck: [],
|
||||
};
|
||||
mockRepo.create.mockResolvedValue({ ...data, _id: 'r1', householdId: 'hh1' });
|
||||
|
||||
const result = await service.create(data, 'hh1');
|
||||
expect(mockRepo.create).toHaveBeenCalledWith({
|
||||
...data,
|
||||
householdId: 'hh1',
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates household rule', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
mockRepo.update.mockResolvedValue({ _id: 'r1', shelfLifeDays: 10 });
|
||||
|
||||
const result = await service.update('r1', 'hh1', { shelfLifeDays: 10 });
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when rule not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.update('missing', 'hh1', {})).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws BadRequestError for system rules', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: null,
|
||||
source: FreshnessRuleSource.SYSTEM,
|
||||
});
|
||||
await expect(service.update('r1', 'hh1', {})).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for another household rule', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: 'other-hh',
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
await expect(service.update('r1', 'hh1', {})).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
mockRepo.update.mockResolvedValue(null);
|
||||
await expect(service.update('r1', 'hh1', {})).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes household rule', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
mockRepo.delete.mockResolvedValue({ _id: 'r1' });
|
||||
await service.delete('r1', 'hh1');
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('r1', 'hh1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when rule not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws BadRequestError for system rules', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: null,
|
||||
source: FreshnessRuleSource.SYSTEM,
|
||||
});
|
||||
await expect(service.delete('r1', 'hh1')).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for another household rule', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'r1',
|
||||
householdId: 'other-hh',
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
await expect(service.delete('r1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { FreshnessRulesRepository } from './freshness-rules.repository.js';
|
||||
import { FreshnessRuleSource } from '@meshitrack/shared';
|
||||
import type {
|
||||
CreateFreshnessRuleInput,
|
||||
UpdateFreshnessRuleInput,
|
||||
FreshnessRuleQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
freshnessRulesRepository: FreshnessRulesRepository;
|
||||
}
|
||||
|
||||
export class FreshnessRulesService {
|
||||
private readonly freshnessRulesRepository: FreshnessRulesRepository;
|
||||
|
||||
public constructor({ freshnessRulesRepository }: Deps) {
|
||||
this.freshnessRulesRepository = freshnessRulesRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: FreshnessRuleQueryInput) {
|
||||
return this.freshnessRulesRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async create(data: CreateFreshnessRuleInput, householdId: string) {
|
||||
return this.freshnessRulesRepository.create({
|
||||
...data,
|
||||
householdId,
|
||||
source: FreshnessRuleSource.HOUSEHOLD,
|
||||
});
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateFreshnessRuleInput) {
|
||||
const existing = await this.freshnessRulesRepository.findById(id);
|
||||
if (!existing) throw new NotFoundError('Freshness rule not found');
|
||||
|
||||
const rec = existing as Record<string, unknown>;
|
||||
if (rec.source === FreshnessRuleSource.SYSTEM && rec.householdId == null) {
|
||||
throw new BadRequestError('Cannot modify system rules. Create a household override instead.');
|
||||
}
|
||||
if (rec.householdId && rec.householdId !== householdId) {
|
||||
throw new NotFoundError('Freshness rule not found');
|
||||
}
|
||||
|
||||
const updated = await this.freshnessRulesRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Freshness rule not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
const existing = await this.freshnessRulesRepository.findById(id);
|
||||
if (!existing) throw new NotFoundError('Freshness rule not found');
|
||||
|
||||
const rec = existing as Record<string, unknown>;
|
||||
if (rec.source === FreshnessRuleSource.SYSTEM && rec.householdId == null) {
|
||||
throw new BadRequestError('Cannot delete system rules');
|
||||
}
|
||||
if (rec.householdId && rec.householdId !== householdId) {
|
||||
throw new NotFoundError('Freshness rule not found');
|
||||
}
|
||||
|
||||
await this.freshnessRulesRepository.delete(id, householdId);
|
||||
}
|
||||
}
|
||||
62
packages/api/src/modules/llm/llm-provider.interface.ts
Normal file
62
packages/api/src/modules/llm/llm-provider.interface.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { NutritionInfo } from '@meshitrack/shared';
|
||||
|
||||
export interface NutritionExtractionResult {
|
||||
name?: string;
|
||||
brand?: string;
|
||||
servingSize?: number;
|
||||
servingUnit?: string;
|
||||
nutrition: Partial<NutritionInfo>;
|
||||
}
|
||||
|
||||
export interface ParsedIngredient {
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface ParsedRecipe {
|
||||
name: string;
|
||||
servings: number;
|
||||
ingredients: ParsedIngredient[];
|
||||
steps: string[];
|
||||
prepTime?: number;
|
||||
cookTime?: number;
|
||||
cuisine?: string;
|
||||
}
|
||||
|
||||
export interface ParsedReceipt {
|
||||
items: Array<{ name: string; quantity?: number; price?: number }>;
|
||||
total?: number;
|
||||
storeName?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface MealPlanContext {
|
||||
householdId: string;
|
||||
targetCalories?: number;
|
||||
preferences?: string[];
|
||||
avoidances?: string[];
|
||||
}
|
||||
|
||||
export interface MealPlanSuggestion {
|
||||
meals: Array<{ name: string; recipeId?: string; calories: number }>;
|
||||
}
|
||||
|
||||
export interface StructuredAction {
|
||||
intent: string;
|
||||
entities: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ILlmProvider {
|
||||
extractNutrition(input: {
|
||||
text?: string;
|
||||
image?: Buffer;
|
||||
}): Promise<NutritionExtractionResult | null>;
|
||||
parseRecipe(text: string): Promise<ParsedRecipe | null>;
|
||||
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
|
||||
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
|
||||
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
|
||||
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||
59
packages/api/src/modules/llm/no-op-llm.provider.test.ts
Normal file
59
packages/api/src/modules/llm/no-op-llm.provider.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { NoOpLlmProvider } from './no-op-llm.provider.js';
|
||||
import { LLM_PROVIDER } from './llm-provider.interface.js';
|
||||
|
||||
describe(NoOpLlmProvider.name, () => {
|
||||
const provider = new NoOpLlmProvider();
|
||||
|
||||
it('exports LLM_PROVIDER symbol', () => {
|
||||
expect(typeof LLM_PROVIDER).toBe('symbol');
|
||||
});
|
||||
|
||||
it('extractNutrition returns null and warns', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await provider.extractNutrition({ text: 'apple' });
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('extractNutrition'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('parseRecipe returns null and warns', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await provider.parseRecipe('pasta recipe');
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseRecipe'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('parseRecipeFromUrl returns null and warns', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await provider.parseRecipeFromUrl('https://example.com');
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseRecipeFromUrl'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('parseReceipt returns null and warns', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await provider.parseReceipt(Buffer.from('fake-image'));
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseReceipt'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('suggestMealPlan returns null and warns', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await provider.suggestMealPlan({ householdId: 'hh1' });
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('suggestMealPlan'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('parseNaturalLanguage returns null and warns', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await provider.parseNaturalLanguage('add milk');
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseNaturalLanguage'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
48
packages/api/src/modules/llm/no-op-llm.provider.ts
Normal file
48
packages/api/src/modules/llm/no-op-llm.provider.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type {
|
||||
ILlmProvider,
|
||||
NutritionExtractionResult,
|
||||
ParsedRecipe,
|
||||
ParsedReceipt,
|
||||
MealPlanContext,
|
||||
MealPlanSuggestion,
|
||||
StructuredAction,
|
||||
} from './llm-provider.interface.js';
|
||||
|
||||
export class NoOpLlmProvider implements ILlmProvider {
|
||||
private warn(method: string): void {
|
||||
console.warn(`[NoOpLlmProvider] ${method} called but no LLM provider is configured.`);
|
||||
}
|
||||
|
||||
public async extractNutrition(_input: {
|
||||
text?: string;
|
||||
image?: Buffer;
|
||||
}): Promise<NutritionExtractionResult | null> {
|
||||
this.warn('extractNutrition');
|
||||
return null;
|
||||
}
|
||||
|
||||
public async parseRecipe(_text: string): Promise<ParsedRecipe | null> {
|
||||
this.warn('parseRecipe');
|
||||
return null;
|
||||
}
|
||||
|
||||
public async parseRecipeFromUrl(_url: string): Promise<ParsedRecipe | null> {
|
||||
this.warn('parseRecipeFromUrl');
|
||||
return null;
|
||||
}
|
||||
|
||||
public async parseReceipt(_image: Buffer): Promise<ParsedReceipt | null> {
|
||||
this.warn('parseReceipt');
|
||||
return null;
|
||||
}
|
||||
|
||||
public async suggestMealPlan(_context: MealPlanContext): Promise<MealPlanSuggestion | null> {
|
||||
this.warn('suggestMealPlan');
|
||||
return null;
|
||||
}
|
||||
|
||||
public async parseNaturalLanguage(_text: string): Promise<StructuredAction | null> {
|
||||
this.warn('parseNaturalLanguage');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
254
packages/api/src/modules/pantry/pantry.repository.test.ts
Normal file
254
packages/api/src/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('../../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);
|
||||
});
|
||||
});
|
||||
});
|
||||
198
packages/api/src/modules/pantry/pantry.repository.ts
Normal file
198
packages/api/src/modules/pantry/pantry.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
393
packages/api/src/modules/pantry/pantry.routes.test.ts
Normal file
393
packages/api/src/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('./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);
|
||||
});
|
||||
});
|
||||
});
|
||||
285
packages/api/src/modules/pantry/pantry.routes.ts
Normal file
285
packages/api/src/modules/pantry/pantry.routes.ts
Normal 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' },
|
||||
);
|
||||
434
packages/api/src/modules/pantry/pantry.service.test.ts
Normal file
434
packages/api/src/modules/pantry/pantry.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
295
packages/api/src/modules/pantry/pantry.service.ts
Normal file
295
packages/api/src/modules/pantry/pantry.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
487
packages/api/src/modules/products/barcode.service.test.ts
Normal file
487
packages/api/src/modules/products/barcode.service.test.ts
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BarcodeService } from './barcode.service.js';
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
request: vi.fn(),
|
||||
}));
|
||||
|
||||
import { request as undiciRequest } from 'undici';
|
||||
|
||||
const mockRequest = undiciRequest as ReturnType<typeof vi.fn>;
|
||||
|
||||
function makeMockRepo() {
|
||||
return {
|
||||
findByBarcode: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('BarcodeService', () => {
|
||||
let service: BarcodeService;
|
||||
let mockRepo: ReturnType<typeof makeMockRepo>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepo = makeMockRepo();
|
||||
service = new BarcodeService({
|
||||
productsRepository: mockRepo as unknown as ConstructorParameters<
|
||||
typeof BarcodeService
|
||||
>[0]['productsRepository'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns cached product from local DB', async () => {
|
||||
const existing = { _id: 'p1', name: 'Test Product', barcode: '1234567890123' };
|
||||
mockRepo.findByBarcode.mockResolvedValue(existing);
|
||||
|
||||
const result = await service.lookup('hh1', '1234567890123', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
if (result.found) {
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.product).toEqual(existing);
|
||||
}
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls Open Food Facts when not found locally and caches result', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
const savedProduct = { _id: 'p2', name: 'Nutella', barcode: '3017620422003' };
|
||||
mockRepo.create.mockResolvedValue(savedProduct);
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Nutella',
|
||||
brands: 'Ferrero',
|
||||
categories_tags: ['en:snacks'],
|
||||
serving_quantity: 15,
|
||||
nutriments: {
|
||||
'energy-kcal_serving': 80,
|
||||
proteins_serving: 0.9,
|
||||
carbohydrates_serving: 8.5,
|
||||
fat_serving: 4.7,
|
||||
fiber_serving: 0.5,
|
||||
sugars_serving: 8.2,
|
||||
'saturated-fat_serving': 1.6,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '3017620422003', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
if (result.found) {
|
||||
expect(result.cached).toBe(false);
|
||||
}
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
householdId: 'hh1',
|
||||
name: 'Nutella',
|
||||
brand: 'Ferrero',
|
||||
barcode: '3017620422003',
|
||||
category: 'snacks',
|
||||
servingSize: 15,
|
||||
servingUnit: 'g',
|
||||
source: 'barcode_lookup',
|
||||
createdBy: 'u1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns found:false when OFF returns 404', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 404,
|
||||
body: { json: vi.fn().mockResolvedValue({}) },
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(mockRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns found:false when OFF returns status 0', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({ status: 0, product: { product_name: 'X' } }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('returns found:false when OFF product has no product_name', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({ status: 1, product: {} }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('returns found:false when network request throws', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRequest.mockRejectedValue(new Error('Connection timeout'));
|
||||
|
||||
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to per-100g nutrition when no serving data', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
const savedProduct = { _id: 'p3', name: 'Plain Rice', barcode: '1111111111111' };
|
||||
mockRepo.create.mockResolvedValue(savedProduct);
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Plain Rice',
|
||||
categories_tags: ['en:cereals'],
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 130,
|
||||
proteins_100g: 2.7,
|
||||
carbohydrates_100g: 28,
|
||||
fat_100g: 0.3,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '1111111111111', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
servingSize: 100,
|
||||
nutrition: expect.objectContaining({
|
||||
calories: 130,
|
||||
protein: 2.7,
|
||||
carbs: 28,
|
||||
fat: 0.3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps category from OFF categories_tags', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p4', name: 'Milk' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Milk',
|
||||
categories_tags: ['en:dairies'],
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 60,
|
||||
proteins_100g: 3.3,
|
||||
carbohydrates_100g: 4.7,
|
||||
fat_100g: 3.2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '2222222222222', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ category: 'dairy' }));
|
||||
});
|
||||
|
||||
it('parses serving_size string when serving_quantity is absent', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p5', name: 'Yogurt' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Yogurt',
|
||||
serving_size: '125 g',
|
||||
nutriments: {
|
||||
'energy-kcal_serving': 110,
|
||||
proteins_serving: 5,
|
||||
carbohydrates_serving: 15,
|
||||
fat_serving: 3,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '3333333333333', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 125 }));
|
||||
});
|
||||
|
||||
it('converts sodium and cholesterol from grams to milligrams', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p6', name: 'Soup' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Soup',
|
||||
serving_quantity: 250,
|
||||
nutriments: {
|
||||
'energy-kcal_serving': 90,
|
||||
proteins_serving: 4,
|
||||
carbohydrates_serving: 12,
|
||||
fat_serving: 2,
|
||||
sodium_serving: 0.8,
|
||||
cholesterol_serving: 0.015,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '4444444444444', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nutrition: expect.objectContaining({
|
||||
sodium: 800,
|
||||
cholesterol: 15,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles brand with multiple comma-separated values by taking first', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p7', name: 'Multi Brand' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Multi Brand',
|
||||
brands: 'BrandA, BrandB, BrandC',
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 100,
|
||||
proteins_100g: 5,
|
||||
carbohydrates_100g: 20,
|
||||
fat_100g: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '5555555555555', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ brand: 'BrandA' }));
|
||||
});
|
||||
|
||||
it('returns found:false when OFF product field is missing', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({ status: 1 }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '6666666666666', 'u1');
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults category to OTHER when no matching tags', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p8', name: 'Unknown' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Unknown',
|
||||
categories_tags: ['en:unknown-stuff'],
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 50,
|
||||
proteins_100g: 1,
|
||||
carbohydrates_100g: 10,
|
||||
fat_100g: 0.5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '7777777777777', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ category: 'other' }));
|
||||
});
|
||||
|
||||
it('handles serving_size with no numeric value', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p9', name: 'Weird Serving' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Weird Serving',
|
||||
serving_size: 'one portion',
|
||||
nutriments: {
|
||||
'energy-kcal_serving': 100,
|
||||
proteins_serving: 5,
|
||||
carbohydrates_serving: 10,
|
||||
fat_serving: 3,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '8888888888888', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 100 }));
|
||||
});
|
||||
|
||||
it('defaults nutrition to zeros when nutriments is undefined', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p10', name: 'No Nutrition' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'No Nutrition',
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '9999999999999', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults serving size to 100 when serving_quantity is negative', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p11', name: 'Negative QTY' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Negative QTY',
|
||||
serving_quantity: -1,
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 50,
|
||||
proteins_100g: 2,
|
||||
carbohydrates_100g: 8,
|
||||
fat_100g: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '1010101010101', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 100 }));
|
||||
});
|
||||
|
||||
it('uses serving fallbacks when _serving nutriments are missing', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'p12', name: 'Partial Nutrients' });
|
||||
|
||||
mockRequest.mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
status: 1,
|
||||
product: {
|
||||
product_name: 'Partial Nutrients',
|
||||
serving_quantity: 50,
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 200,
|
||||
proteins_100g: 10,
|
||||
carbohydrates_100g: 30,
|
||||
fat_100g: 5,
|
||||
fiber_100g: 3,
|
||||
sugars_100g: 12,
|
||||
sodium_100g: 0.4,
|
||||
'saturated-fat_100g': 1.5,
|
||||
cholesterol_100g: 0.02,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.lookup('hh1', '1212121212121', 'u1');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
servingSize: 50,
|
||||
nutrition: expect.objectContaining({
|
||||
calories: 200,
|
||||
protein: 10,
|
||||
carbs: 30,
|
||||
fat: 5,
|
||||
fiber: 3,
|
||||
sugar: 12,
|
||||
sodium: 400,
|
||||
saturatedFat: 1.5,
|
||||
cholesterol: 20,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
210
packages/api/src/modules/products/barcode.service.ts
Normal file
210
packages/api/src/modules/products/barcode.service.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { request } from 'undici';
|
||||
import type { ProductsRepository } from './products.repository.js';
|
||||
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||
|
||||
interface Deps {
|
||||
productsRepository: ProductsRepository;
|
||||
}
|
||||
|
||||
interface BarcodeLookupSuccess {
|
||||
found: true;
|
||||
product: Record<string, unknown>;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
interface BarcodeLookupNotFound {
|
||||
found: false;
|
||||
}
|
||||
|
||||
export type BarcodeLookupResult = BarcodeLookupSuccess | BarcodeLookupNotFound;
|
||||
|
||||
const OFF_BASE_URL = 'https://world.openfoodfacts.org/api/v2/product';
|
||||
const TIMEOUT_MS = 5000;
|
||||
const USER_AGENT = 'MeshiTrack/0.1.0 (+self-hosted)';
|
||||
|
||||
const CATEGORY_MAP: Record<string, ProductCategory> = {
|
||||
'en:dairies': ProductCategory.DAIRY,
|
||||
'en:meats': ProductCategory.MEAT,
|
||||
'en:poultry': ProductCategory.POULTRY,
|
||||
'en:fishes': ProductCategory.SEAFOOD,
|
||||
'en:seafood': ProductCategory.SEAFOOD,
|
||||
'en:fruits': ProductCategory.FRUITS,
|
||||
'en:vegetables': ProductCategory.VEGETABLES,
|
||||
'en:cereals-and-potatoes': ProductCategory.GRAINS,
|
||||
'en:cereals': ProductCategory.GRAINS,
|
||||
'en:legumes': ProductCategory.LEGUMES,
|
||||
'en:nuts': ProductCategory.NUTS_SEEDS,
|
||||
'en:fats': ProductCategory.OILS_FATS,
|
||||
'en:sauces': ProductCategory.CONDIMENTS,
|
||||
'en:condiments': ProductCategory.CONDIMENTS,
|
||||
'en:spices': ProductCategory.SPICES,
|
||||
'en:beverages': ProductCategory.BEVERAGES,
|
||||
'en:snacks': ProductCategory.SNACKS,
|
||||
'en:frozen-foods': ProductCategory.FROZEN,
|
||||
'en:canned-foods': ProductCategory.CANNED,
|
||||
'en:breads': ProductCategory.BAKERY,
|
||||
'en:supplements': ProductCategory.SUPPLEMENTS,
|
||||
};
|
||||
|
||||
function mapCategory(tags: string[] | undefined): ProductCategory {
|
||||
if (!tags || tags.length === 0) return ProductCategory.OTHER;
|
||||
for (const tag of tags) {
|
||||
const category = CATEGORY_MAP[tag];
|
||||
if (category) return category;
|
||||
}
|
||||
return ProductCategory.OTHER;
|
||||
}
|
||||
|
||||
interface OffNutriments {
|
||||
'energy-kcal_serving'?: number;
|
||||
'energy-kcal_100g'?: number;
|
||||
proteins_serving?: number;
|
||||
proteins_100g?: number;
|
||||
carbohydrates_serving?: number;
|
||||
carbohydrates_100g?: number;
|
||||
fat_serving?: number;
|
||||
fat_100g?: number;
|
||||
fiber_serving?: number;
|
||||
fiber_100g?: number;
|
||||
sugars_serving?: number;
|
||||
sugars_100g?: number;
|
||||
sodium_serving?: number;
|
||||
sodium_100g?: number;
|
||||
'saturated-fat_serving'?: number;
|
||||
'saturated-fat_100g'?: number;
|
||||
cholesterol_serving?: number;
|
||||
cholesterol_100g?: number;
|
||||
}
|
||||
|
||||
interface OffProduct {
|
||||
product_name?: string;
|
||||
brands?: string;
|
||||
categories_tags?: string[];
|
||||
serving_size?: string;
|
||||
serving_quantity?: number;
|
||||
nutriments?: OffNutriments;
|
||||
}
|
||||
|
||||
function parseServingSize(servingSize?: string, servingQuantity?: number): number {
|
||||
if (servingQuantity && servingQuantity > 0) return servingQuantity;
|
||||
if (!servingSize) return 100;
|
||||
const match = servingSize.match(/(\d+(?:\.\d+)?)/);
|
||||
return match ? parseFloat(match[1]!) : 100;
|
||||
}
|
||||
|
||||
function mapNutrition(nutriments: OffNutriments | undefined, hasServing: boolean) {
|
||||
if (!nutriments) {
|
||||
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
|
||||
}
|
||||
const suffix = hasServing ? '_serving' : '_100g';
|
||||
const calories =
|
||||
nutriments[`energy-kcal${suffix}` as keyof OffNutriments] ??
|
||||
nutriments['energy-kcal_100g'] ??
|
||||
0;
|
||||
const protein =
|
||||
nutriments[`proteins${suffix}` as keyof OffNutriments] ?? nutriments['proteins_100g'] ?? 0;
|
||||
const carbs =
|
||||
nutriments[`carbohydrates${suffix}` as keyof OffNutriments] ??
|
||||
nutriments['carbohydrates_100g'] ??
|
||||
0;
|
||||
const fat = nutriments[`fat${suffix}` as keyof OffNutriments] ?? nutriments['fat_100g'] ?? 0;
|
||||
|
||||
const result: Record<string, number> = {
|
||||
calories: Number(calories),
|
||||
protein: Number(protein),
|
||||
carbs: Number(carbs),
|
||||
fat: Number(fat),
|
||||
};
|
||||
|
||||
const fiber = nutriments[`fiber${suffix}` as keyof OffNutriments] ?? nutriments['fiber_100g'];
|
||||
if (fiber != null) result['fiber'] = Number(fiber);
|
||||
|
||||
const sugars = nutriments[`sugars${suffix}` as keyof OffNutriments] ?? nutriments['sugars_100g'];
|
||||
if (sugars != null) result['sugar'] = Number(sugars);
|
||||
|
||||
const sodium = nutriments[`sodium${suffix}` as keyof OffNutriments] ?? nutriments['sodium_100g'];
|
||||
if (sodium != null) result['sodium'] = Number(sodium) * 1000; // g → mg
|
||||
|
||||
const saturatedFat =
|
||||
nutriments[`saturated-fat${suffix}` as keyof OffNutriments] ?? nutriments['saturated-fat_100g'];
|
||||
if (saturatedFat != null) result['saturatedFat'] = Number(saturatedFat);
|
||||
|
||||
const cholesterol =
|
||||
nutriments[`cholesterol${suffix}` as keyof OffNutriments] ?? nutriments['cholesterol_100g'];
|
||||
if (cholesterol != null) result['cholesterol'] = Number(cholesterol) * 1000; // g → mg
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class BarcodeService {
|
||||
private readonly productsRepository: ProductsRepository;
|
||||
|
||||
public constructor({ productsRepository }: Deps) {
|
||||
this.productsRepository = productsRepository;
|
||||
}
|
||||
|
||||
public async lookup(
|
||||
householdId: string,
|
||||
barcode: string,
|
||||
createdBy: string,
|
||||
): Promise<BarcodeLookupResult> {
|
||||
// 1. Check local DB first
|
||||
const existing = await this.productsRepository.findByBarcode(householdId, barcode);
|
||||
if (existing) {
|
||||
return { found: true, product: existing as unknown as Record<string, unknown>, cached: true };
|
||||
}
|
||||
|
||||
// 2. Call Open Food Facts API
|
||||
try {
|
||||
const { statusCode, body } = await request(`${OFF_BASE_URL}/${barcode}`, {
|
||||
method: 'GET',
|
||||
headersTimeout: TIMEOUT_MS,
|
||||
bodyTimeout: TIMEOUT_MS,
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
});
|
||||
|
||||
if (statusCode !== 200) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
const json = (await body.json()) as { status?: number; product?: OffProduct };
|
||||
if (!json.product || json.status === 0) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
const offProduct = json.product;
|
||||
if (!offProduct.product_name) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
// 3. Map OFF response to Product shape
|
||||
const hasServing = Boolean(offProduct.serving_quantity || offProduct.serving_size);
|
||||
const servingSize = hasServing
|
||||
? parseServingSize(offProduct.serving_size, offProduct.serving_quantity)
|
||||
: 100;
|
||||
|
||||
const nutrition = mapNutrition(offProduct.nutriments, hasServing);
|
||||
const brand = offProduct.brands?.split(',')[0]?.trim();
|
||||
|
||||
const productData = {
|
||||
householdId,
|
||||
name: offProduct.product_name,
|
||||
...(brand ? { brand } : {}),
|
||||
barcode,
|
||||
category: mapCategory(offProduct.categories_tags),
|
||||
servingSize,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition,
|
||||
tags: [] as string[],
|
||||
source: ProductSource.BARCODE_LOOKUP,
|
||||
createdBy,
|
||||
};
|
||||
|
||||
// 4. Cache in local DB
|
||||
const saved = await this.productsRepository.create(productData);
|
||||
return { found: true, product: saved as unknown as Record<string, unknown>, cached: false };
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
173
packages/api/src/modules/products/csv-parser.test.ts
Normal file
173
packages/api/src/modules/products/csv-parser.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCsv, MAX_FILE_SIZE, MAX_ROWS } from './csv-parser.js';
|
||||
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('parses a valid CSV with all columns', () => {
|
||||
const csv = [
|
||||
'name,brand,barcode,category,servingSize,servingUnit,densityGPerMl,calories,protein,carbs,fat,fiber,sugar,sodium,saturatedFat,cholesterol,tags',
|
||||
'Chicken Breast,Tyson,1234567890123,meat,100,g,,165,31,0,3.6,0,0,74,1,85,protein;lean',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
name: 'Chicken Breast',
|
||||
brand: 'Tyson',
|
||||
barcode: '1234567890123',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: ['protein', 'lean'],
|
||||
source: ProductSource.IMPORT,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles minimal CSV with only name column', () => {
|
||||
const csv = 'name\nRice\nBeans';
|
||||
const result = parseCsv(csv);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]!.name).toBe('Rice');
|
||||
expect(result.items[0]!.category).toBe(ProductCategory.OTHER);
|
||||
expect(result.items[0]!.servingUnit).toBe(ServingUnit.GRAMS);
|
||||
expect(result.items[0]!.servingSize).toBe(100);
|
||||
});
|
||||
|
||||
it('returns error for empty file', () => {
|
||||
const result = parseCsv('');
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.message).toBe('Empty file');
|
||||
});
|
||||
|
||||
it('returns error when name column is missing', () => {
|
||||
const csv = 'brand,category\nNikko,meat';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.message).toContain('Missing required "name" column');
|
||||
});
|
||||
|
||||
it('skips rows with empty name', () => {
|
||||
const csv = 'name,category\n,meat\nChicken,meat';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.message).toContain('Missing required field: name');
|
||||
});
|
||||
|
||||
it('rejects invalid servingUnit', () => {
|
||||
const csv = 'name,servingUnit\nFlour,cup';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.message).toContain('Invalid servingUnit');
|
||||
expect(result.errors[0]!.message).toContain('cup');
|
||||
});
|
||||
|
||||
it('rejects negative servingSize', () => {
|
||||
const csv = 'name,servingSize\nBad,-10';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.message).toContain('servingSize must be a positive number');
|
||||
});
|
||||
|
||||
it('handles quoted fields with commas', () => {
|
||||
const csv = 'name,brand\n"Peanut Butter, Crunchy",Jif';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.name).toBe('Peanut Butter, Crunchy');
|
||||
expect(result.items[0]!.brand).toBe('Jif');
|
||||
});
|
||||
|
||||
it('handles escaped quotes in CSV', () => {
|
||||
const csv = 'name,brand\n"8"" Pizza",DiGiorno';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.name).toBe('8" Pizza');
|
||||
});
|
||||
|
||||
it('uses ml serving unit when specified', () => {
|
||||
const csv = 'name,servingUnit\nMilk,ml';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.servingUnit).toBe(ServingUnit.MILLILITERS);
|
||||
});
|
||||
|
||||
it('includes densityGPerMl when provided', () => {
|
||||
const csv = 'name,densityGPerMl\nOlive Oil,0.92';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.densityGPerMl).toBe(0.92);
|
||||
});
|
||||
|
||||
it('parses optional nutrition fields', () => {
|
||||
const csv =
|
||||
'name,calories,protein,carbs,fat,fiber,sugar,sodium,saturatedFat,cholesterol\nEgg,155,13,1.1,11,0,1.1,124,3.3,373';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.nutrition).toEqual({
|
||||
calories: 155,
|
||||
protein: 13,
|
||||
carbs: 1.1,
|
||||
fat: 11,
|
||||
fiber: 0,
|
||||
sugar: 1.1,
|
||||
sodium: 124,
|
||||
saturatedFat: 3.3,
|
||||
cholesterol: 373,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Windows line endings (CRLF)', () => {
|
||||
const csv = 'name,category\r\nApple,fruits\r\nBanana,fruits';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ignores blank lines', () => {
|
||||
const csv = 'name\n\nApple\n\nBanana\n';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps valid category strings', () => {
|
||||
const csv = 'name,category\nYogurt,dairy\nSalmon,seafood';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items[0]!.category).toBe(ProductCategory.DAIRY);
|
||||
expect(result.items[1]!.category).toBe(ProductCategory.SEAFOOD);
|
||||
});
|
||||
|
||||
it('defaults invalid category to OTHER', () => {
|
||||
const csv = 'name,category\nMystery,invalid_cat';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items[0]!.category).toBe(ProductCategory.OTHER);
|
||||
});
|
||||
|
||||
it('exports MAX_FILE_SIZE and MAX_ROWS constants', () => {
|
||||
expect(MAX_FILE_SIZE).toBe(5 * 1024 * 1024);
|
||||
expect(MAX_ROWS).toBe(5000);
|
||||
});
|
||||
|
||||
it('handles non-numeric servingSize as error', () => {
|
||||
const csv = 'name,servingSize\nBad,abc';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]!.message).toContain('servingSize must be a positive number');
|
||||
});
|
||||
|
||||
it('handles case-insensitive headers', () => {
|
||||
const csv = 'Name,Brand,Category,ServingSize,ServingUnit\nTest,Brand1,meat,50,g';
|
||||
const result = parseCsv(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.name).toBe('Test');
|
||||
expect(result.items[0]!.brand).toBe('Brand1');
|
||||
});
|
||||
});
|
||||
167
packages/api/src/modules/products/csv-parser.ts
Normal file
167
packages/api/src/modules/products/csv-parser.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { CreateProductInput } from '@meshitrack/shared';
|
||||
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||
|
||||
const VALID_CATEGORIES = new Set(Object.values(ProductCategory));
|
||||
const VALID_UNITS = new Set(Object.values(ServingUnit));
|
||||
|
||||
export interface CsvParseResult {
|
||||
items: CreateProductInput[];
|
||||
errors: { row: number; message: string }[];
|
||||
}
|
||||
|
||||
const _EXPECTED_HEADERS = [
|
||||
'name',
|
||||
'brand',
|
||||
'barcode',
|
||||
'category',
|
||||
'servingSize',
|
||||
'servingUnit',
|
||||
'densityGPerMl',
|
||||
'calories',
|
||||
'protein',
|
||||
'carbs',
|
||||
'fat',
|
||||
'fiber',
|
||||
'sugar',
|
||||
'sodium',
|
||||
'saturatedFat',
|
||||
'cholesterol',
|
||||
'tags',
|
||||
];
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!;
|
||||
if (ch === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
fields.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseCsv(content: string): CsvParseResult {
|
||||
const lines = content.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return { items: [], errors: [{ row: 0, message: 'Empty file' }] };
|
||||
}
|
||||
|
||||
const headers = parseCsvLine(lines[0]!).map((h) => h.toLowerCase().trim());
|
||||
const nameIdx = headers.indexOf('name');
|
||||
if (nameIdx === -1) {
|
||||
return { items: [], errors: [{ row: 0, message: 'Missing required "name" column' }] };
|
||||
}
|
||||
|
||||
const items: CreateProductInput[] = [];
|
||||
const errors: { row: number; message: string }[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = i + 1;
|
||||
const fields = parseCsvLine(lines[i]!);
|
||||
try {
|
||||
const get = (header: string) => {
|
||||
const idx = headers.indexOf(header);
|
||||
return idx >= 0 && idx < fields.length ? fields[idx]! : '';
|
||||
};
|
||||
|
||||
const name = get('name');
|
||||
if (!name) {
|
||||
errors.push({ row, message: 'Missing required field: name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryStr = get('category');
|
||||
const category =
|
||||
categoryStr && VALID_CATEGORIES.has(categoryStr as ProductCategory)
|
||||
? (categoryStr as ProductCategory)
|
||||
: ProductCategory.OTHER;
|
||||
|
||||
const unitStr = get('servingunit');
|
||||
if (unitStr && !VALID_UNITS.has(unitStr as ServingUnit)) {
|
||||
errors.push({
|
||||
row,
|
||||
message: `Invalid servingUnit: "${unitStr}". Must be g, ml, piece, or slice`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const servingUnit =
|
||||
unitStr && VALID_UNITS.has(unitStr as ServingUnit)
|
||||
? (unitStr as ServingUnit)
|
||||
: ServingUnit.GRAMS;
|
||||
|
||||
const servingSizeStr = get('servingsize');
|
||||
const servingSize = servingSizeStr ? parseFloat(servingSizeStr) : 100;
|
||||
if (isNaN(servingSize) || servingSize <= 0) {
|
||||
errors.push({ row, message: 'servingSize must be a positive number' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const calories = parseFloat(get('calories') || '0') || 0;
|
||||
const protein = parseFloat(get('protein') || '0') || 0;
|
||||
const carbs = parseFloat(get('carbs') || '0') || 0;
|
||||
const fat = parseFloat(get('fat') || '0') || 0;
|
||||
|
||||
const nutrition: Record<string, number> = { calories, protein, carbs, fat };
|
||||
|
||||
const fiber = get('fiber');
|
||||
if (fiber) nutrition['fiber'] = parseFloat(fiber) || 0;
|
||||
const sugar = get('sugar');
|
||||
if (sugar) nutrition['sugar'] = parseFloat(sugar) || 0;
|
||||
const sodium = get('sodium');
|
||||
if (sodium) nutrition['sodium'] = parseFloat(sodium) || 0;
|
||||
const saturatedFat = get('saturatedfat');
|
||||
if (saturatedFat) nutrition['saturatedFat'] = parseFloat(saturatedFat) || 0;
|
||||
const cholesterol = get('cholesterol');
|
||||
if (cholesterol) nutrition['cholesterol'] = parseFloat(cholesterol) || 0;
|
||||
|
||||
const tagsStr = get('tags');
|
||||
const tags = tagsStr
|
||||
? tagsStr
|
||||
.split(';')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const brand = get('brand') || undefined;
|
||||
const barcode = get('barcode') || undefined;
|
||||
const densityStr = get('densitygperml');
|
||||
const densityGPerMl = densityStr ? parseFloat(densityStr) : undefined;
|
||||
|
||||
const item: CreateProductInput = {
|
||||
name,
|
||||
category,
|
||||
servingSize,
|
||||
servingUnit,
|
||||
nutrition: nutrition as CreateProductInput['nutrition'],
|
||||
tags,
|
||||
source: ProductSource.IMPORT,
|
||||
...(brand ? { brand } : {}),
|
||||
...(barcode ? { barcode } : {}),
|
||||
...(densityGPerMl && !isNaN(densityGPerMl) ? { densityGPerMl } : {}),
|
||||
};
|
||||
|
||||
items.push(item);
|
||||
} catch /* v8 ignore next */ {
|
||||
errors.push({ row, message: 'Failed to parse row' });
|
||||
}
|
||||
}
|
||||
|
||||
return { items, errors };
|
||||
}
|
||||
|
||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
export const MAX_ROWS = 5000;
|
||||
298
packages/api/src/modules/products/products.repository.test.ts
Normal file
298
packages/api/src/modules/products/products.repository.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProductsRepository } from './products.repository.js';
|
||||
|
||||
const { mockSave, MockProductModel } = vi.hoisted(() => {
|
||||
const mockSave = vi.fn();
|
||||
function MockProductModel(this: { save: typeof mockSave }, data: unknown) {
|
||||
Object.assign(this, data);
|
||||
this.save = mockSave;
|
||||
}
|
||||
Object.assign(MockProductModel, {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOneAndUpdate: vi.fn(),
|
||||
insertMany: vi.fn(),
|
||||
});
|
||||
return { mockSave, MockProductModel };
|
||||
});
|
||||
|
||||
vi.mock('../../schemas/product.schema.js', () => ({
|
||||
ProductModel: MockProductModel,
|
||||
}));
|
||||
|
||||
const { ProductModel } = await import('../../schemas/product.schema.js');
|
||||
|
||||
function makeChain(result: unknown = null) {
|
||||
return {
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe(ProductsRepository.name, () => {
|
||||
let repo: ProductsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new ProductsRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('applies householdId and deletedAt filters', async () => {
|
||||
const chain = makeChain([]);
|
||||
vi.mocked(ProductModel.find).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ householdId: 'hh1', deletedAt: { $exists: false } }),
|
||||
);
|
||||
expect(chain.sort).toHaveBeenCalledWith({ _id: 1 });
|
||||
expect(chain.limit).toHaveBeenCalledWith(21);
|
||||
});
|
||||
|
||||
it('applies category filter', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, category: 'meat' as never });
|
||||
|
||||
expect(ProductModel.find).toHaveBeenCalledWith(expect.objectContaining({ category: 'meat' }));
|
||||
});
|
||||
|
||||
it('applies barcode filter', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, barcode: '1234567890' });
|
||||
|
||||
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ barcode: '1234567890' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies text search via q', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, q: 'chicken' });
|
||||
|
||||
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: { $regex: 'chicken', $options: 'i' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies tags filter', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, tags: 'organic,fresh' });
|
||||
|
||||
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tags: { $all: ['organic', 'fresh'] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores empty tags string', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, tags: '' });
|
||||
|
||||
const call = vi.mocked(ProductModel.find).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(call).not.toHaveProperty('tags');
|
||||
});
|
||||
|
||||
it('applies cursor filter', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
const cursor = Buffer.from('p1').toString('base64');
|
||||
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||
|
||||
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: { $gt: 'p1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns hasMore=true when extra item exists', async () => {
|
||||
const items = Array.from({ length: 21 }, (_, i) => ({
|
||||
_id: { toString: () => `p${i}` },
|
||||
name: `Item ${i}`,
|
||||
}));
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain(items) as never);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.data).toHaveLength(20);
|
||||
expect(result.pagination.cursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns hasMore=false and null cursor when empty', async () => {
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('queries by id and householdId without deletedAt filter', async () => {
|
||||
const mockProduct = { _id: 'p1', name: 'Apple', householdId: 'hh1' };
|
||||
const chain = {
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(mockProduct),
|
||||
};
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
const result = await repo.findById('p1', 'hh1');
|
||||
expect(ProductModel.findOne).toHaveBeenCalledWith({ _id: 'p1', householdId: 'hh1' });
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIds', () => {
|
||||
it('queries by multiple ids and householdId', async () => {
|
||||
const products = [
|
||||
{ _id: 'p1', name: 'Apple' },
|
||||
{ _id: 'p2', name: 'Banana' },
|
||||
];
|
||||
vi.mocked(ProductModel.find).mockReturnValue(makeChain(products) as never);
|
||||
|
||||
const result = await repo.findByIds('hh1', ['p1', 'p2']);
|
||||
expect(ProductModel.find).toHaveBeenCalledWith({
|
||||
_id: { $in: ['p1', 'p2'] },
|
||||
householdId: 'hh1',
|
||||
});
|
||||
expect(result).toEqual(products);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByBarcode', () => {
|
||||
it('queries by householdId, barcode, and excludes deleted', async () => {
|
||||
const mockProduct = { _id: 'p1', barcode: '1234567890' };
|
||||
const chain = {
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(mockProduct),
|
||||
};
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
const result = await repo.findByBarcode('hh1', '1234567890');
|
||||
expect(ProductModel.findOne).toHaveBeenCalledWith({
|
||||
householdId: 'hh1',
|
||||
barcode: '1234567890',
|
||||
deletedAt: { $exists: false },
|
||||
});
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicate', () => {
|
||||
it('queries by householdId and name', async () => {
|
||||
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findDuplicate('hh1', 'Apple');
|
||||
expect(ProductModel.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
householdId: 'hh1',
|
||||
name: 'Apple',
|
||||
deletedAt: { $exists: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes brand in filter when provided', async () => {
|
||||
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findDuplicate('hh1', 'Apple', 'Dole');
|
||||
expect(ProductModel.findOne).toHaveBeenCalledWith(expect.objectContaining({ brand: 'Dole' }));
|
||||
});
|
||||
|
||||
it('excludes the given id when excludeId provided', async () => {
|
||||
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findDuplicate('hh1', 'Apple', undefined, 'p1');
|
||||
expect(ProductModel.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: { $ne: 'p1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not include _id filter when no excludeId', async () => {
|
||||
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findDuplicate('hh1', 'Apple');
|
||||
const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(call).not.toHaveProperty('_id');
|
||||
});
|
||||
|
||||
it('does not include brand filter when brand is undefined', async () => {
|
||||
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findDuplicate('hh1', 'Apple', undefined);
|
||||
const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(call).not.toHaveProperty('brand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('calls findOneAndUpdate with correct filter and data', async () => {
|
||||
vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'p1' }) as never);
|
||||
|
||||
await repo.update('p1', 'hh1', { name: 'Updated' });
|
||||
|
||||
expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
|
||||
{ $set: { name: 'Updated' } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('sets deletedAt on the document', async () => {
|
||||
vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(
|
||||
makeChain({ _id: 'p1', deletedAt: new Date() }) as never,
|
||||
);
|
||||
|
||||
await repo.softDelete('p1', 'hh1');
|
||||
|
||||
expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
|
||||
{ $set: { deletedAt: expect.any(Date) } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns the new document as plain object', async () => {
|
||||
const plainDoc = { _id: 'new-id', name: 'Apple' };
|
||||
mockSave.mockResolvedValue({ toObject: () => plainDoc });
|
||||
|
||||
const result = await repo.create({ name: 'Apple' });
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(plainDoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkCreate', () => {
|
||||
it('calls insertMany with householdId merged into each item', async () => {
|
||||
vi.mocked(ProductModel.insertMany).mockResolvedValue([] as never);
|
||||
|
||||
await repo.bulkCreate('hh1', [{ name: 'Apple' }, { name: 'Banana' }]);
|
||||
|
||||
expect(ProductModel.insertMany).toHaveBeenCalledWith(
|
||||
[
|
||||
{ name: 'Apple', householdId: 'hh1' },
|
||||
{ name: 'Banana', householdId: 'hh1' },
|
||||
],
|
||||
{ ordered: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
packages/api/src/modules/products/products.repository.ts
Normal file
98
packages/api/src/modules/products/products.repository.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { ProductModel } from '../../schemas/product.schema.js';
|
||||
import type { ProductQueryInput } from '@meshitrack/shared';
|
||||
|
||||
export class ProductsRepository {
|
||||
public async findByHousehold(householdId: string, query: ProductQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId, deletedAt: { $exists: false } };
|
||||
|
||||
if (query.category) filter['category'] = query.category;
|
||||
if (query.barcode) filter['barcode'] = query.barcode;
|
||||
if (query.q) filter['name'] = { $regex: query.q, $options: 'i' };
|
||||
if (query.tags) {
|
||||
const tagList = query.tags.split(',').filter(Boolean);
|
||||
if (tagList.length > 0) filter['tags'] = { $all: tagList };
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await ProductModel.find(filter)
|
||||
.sort({ _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) {
|
||||
// Returns the product regardless of soft-delete status so historical references
|
||||
// in recipes, pantry, and grocery remain resolvable.
|
||||
return ProductModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
|
||||
public async findByIds(householdId: string, ids: string[]) {
|
||||
return ProductModel.find({ _id: { $in: ids }, householdId })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async findByBarcode(householdId: string, barcode: string) {
|
||||
return ProductModel.findOne({ householdId, barcode, deletedAt: { $exists: false } })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async findDuplicate(
|
||||
householdId: string,
|
||||
name: string,
|
||||
brand?: string,
|
||||
excludeId?: string,
|
||||
) {
|
||||
const filter: Record<string, unknown> = {
|
||||
householdId,
|
||||
name,
|
||||
deletedAt: { $exists: false },
|
||||
};
|
||||
if (brand !== undefined) filter['brand'] = brand;
|
||||
if (excludeId) filter['_id'] = { $ne: excludeId };
|
||||
return ProductModel.findOne(filter).lean().exec();
|
||||
}
|
||||
|
||||
public async create(data: Record<string, unknown>) {
|
||||
const doc = new ProductModel(data);
|
||||
const saved = await doc.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: Record<string, unknown>) {
|
||||
return ProductModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string) {
|
||||
return ProductModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||
{ $set: { deletedAt: new Date() } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async bulkCreate(householdId: string, items: Record<string, unknown>[]) {
|
||||
const docs = items.map((item) => ({ ...item, householdId }));
|
||||
return ProductModel.insertMany(docs, { ordered: false });
|
||||
}
|
||||
}
|
||||
579
packages/api/src/modules/products/products.routes.test.ts
Normal file
579
packages/api/src/modules/products/products.routes.test.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
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';
|
||||
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||
|
||||
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,
|
||||
mockFindByBarcode,
|
||||
mockFindDuplicate,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockSoftDelete,
|
||||
mockBulkCreate,
|
||||
mockBarcodeLookup,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockFindByBarcode: vi.fn(),
|
||||
mockFindDuplicate: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockSoftDelete: vi.fn(),
|
||||
mockBulkCreate: vi.fn(),
|
||||
mockBarcodeLookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findByIds = vi.fn();
|
||||
findByBarcode = mockFindByBarcode;
|
||||
findDuplicate = mockFindDuplicate;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
softDelete = mockSoftDelete;
|
||||
bulkCreate = mockBulkCreate;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./barcode.service.js', () => ({
|
||||
BarcodeService: class {
|
||||
lookup = mockBarcodeLookup;
|
||||
},
|
||||
}));
|
||||
|
||||
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 productsRoutes from './products.routes.js';
|
||||
|
||||
function makeFakeProduct(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Chicken Breast',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: ProductSource.MANUAL,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('products.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(productsRoutes);
|
||||
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 /api/v1/households/:householdId/products', () => {
|
||||
it('returns paginated list', async () => {
|
||||
const product = makeFakeProduct();
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [product],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe('Chicken Breast');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query params to service', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products?q=chicken&category=meat&limit=10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockFindByHousehold).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ q: 'chicken', category: ProductCategory.MEAT, limit: 10 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles ObjectId and Date objects in response', async () => {
|
||||
const product = makeFakeProduct({
|
||||
_id: { toString: () => 'pid-obj' },
|
||||
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
|
||||
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
|
||||
brand: 'Tyson',
|
||||
densityGPerMl: 1.05,
|
||||
});
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [product],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0]._id).toBe('pid-obj');
|
||||
expect(body.data[0].brand).toBe('Tyson');
|
||||
expect(body.data[0].densityGPerMl).toBe(1.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/products/barcode/:code', () => {
|
||||
it('returns product when found by barcode', async () => {
|
||||
mockBarcodeLookup.mockResolvedValue({
|
||||
found: true,
|
||||
product: makeFakeProduct({ barcode: '1234567890' }),
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products/barcode/1234567890',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Chicken Breast');
|
||||
});
|
||||
|
||||
it('returns 404 when barcode not found', async () => {
|
||||
mockBarcodeLookup.mockResolvedValue({ found: false });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products/barcode/9999999999',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/products/:id', () => {
|
||||
it('returns a product', async () => {
|
||||
mockFindById.mockResolvedValue(makeFakeProduct());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products/p1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Chicken Breast');
|
||||
});
|
||||
|
||||
it('returns 404 when product not found', async () => {
|
||||
mockFindById.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products/missing',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/products', () => {
|
||||
it('creates a product and returns 201', async () => {
|
||||
mockFindByBarcode.mockResolvedValue(null);
|
||||
mockFindDuplicate.mockResolvedValue(null);
|
||||
mockCreate.mockResolvedValue(makeFakeProduct());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: {
|
||||
name: 'Chicken Breast',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().name).toBe('Chicken Breast');
|
||||
});
|
||||
|
||||
it('returns 409 on barcode conflict', async () => {
|
||||
mockFindByBarcode.mockResolvedValue(makeFakeProduct());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: {
|
||||
name: 'Chicken Breast',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
barcode: '1234567890',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('returns 400 on validation failure', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { name: '' }, // missing required fields
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/products/:id', () => {
|
||||
it('updates product', async () => {
|
||||
const product = makeFakeProduct();
|
||||
mockFindById.mockResolvedValue(product);
|
||||
mockFindByBarcode.mockResolvedValue(null);
|
||||
mockFindDuplicate.mockResolvedValue(null);
|
||||
mockUpdate.mockResolvedValue({ ...product, name: 'Updated' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/products/p1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { name: 'Updated' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown product', async () => {
|
||||
mockFindById.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/products/missing',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { name: 'X' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/products/:id', () => {
|
||||
it('returns 204 on successful delete', async () => {
|
||||
const product = makeFakeProduct();
|
||||
mockFindById.mockResolvedValue(product);
|
||||
mockSoftDelete.mockResolvedValue({ ...product, deletedAt: new Date() });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/products/p1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown product', async () => {
|
||||
mockFindById.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/products/missing',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/products/smart-add', () => {
|
||||
it('returns available:false with NoOp provider', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/smart-add',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { text: 'chicken breast 100g' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ available: false, message: 'LLM not configured' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/products/import', () => {
|
||||
it('imports products from CSV file', async () => {
|
||||
mockFindByBarcode.mockResolvedValue(null);
|
||||
mockFindDuplicate.mockResolvedValue(null);
|
||||
mockBulkCreate.mockResolvedValue([]);
|
||||
|
||||
const csv =
|
||||
'name,category,servingSize,servingUnit,calories,protein,carbs,fat\nRice,grains,100,g,130,2.7,28,0.3';
|
||||
const boundary = '----FormBoundary';
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="file"; filename="products.csv"',
|
||||
'Content-Type: text/csv',
|
||||
'',
|
||||
csv,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/import',
|
||||
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const json = res.json();
|
||||
expect(json.imported).toBe(1);
|
||||
expect(json.skipped).toBe(0);
|
||||
expect(json.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('imports products from JSON file', async () => {
|
||||
mockFindByBarcode.mockResolvedValue(null);
|
||||
mockFindDuplicate.mockResolvedValue(null);
|
||||
mockBulkCreate.mockResolvedValue([]);
|
||||
|
||||
const jsonData = JSON.stringify([
|
||||
{
|
||||
name: 'Beans',
|
||||
category: 'legumes',
|
||||
servingSize: 100,
|
||||
servingUnit: 'g',
|
||||
nutrition: { calories: 120, protein: 8, carbs: 20, fat: 0.5 },
|
||||
tags: [],
|
||||
},
|
||||
]);
|
||||
const boundary = '----FormBoundary';
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="file"; filename="products.json"',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
jsonData,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/import',
|
||||
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().imported).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 400 when no file uploaded', async () => {
|
||||
const boundary = '----FormBoundary';
|
||||
const body = `--${boundary}--`;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/import',
|
||||
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const boundary = '----FormBoundary';
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="file"; filename="bad.json"',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
'{not valid json',
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/import',
|
||||
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe('Invalid JSON');
|
||||
});
|
||||
|
||||
it('returns 400 when JSON is not an array', async () => {
|
||||
const boundary = '----FormBoundary';
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="file"; filename="obj.json"',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
'{"name": "not an array"}',
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/import',
|
||||
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe('JSON must be an array');
|
||||
});
|
||||
|
||||
it('returns 400 when JSON exceeds max rows', async () => {
|
||||
const items = Array.from({ length: 5001 }, (_, i) => ({
|
||||
name: `Item ${i}`,
|
||||
category: 'other',
|
||||
servingSize: 100,
|
||||
servingUnit: 'g',
|
||||
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
tags: [],
|
||||
}));
|
||||
const boundary = '----FormBoundary';
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="file"; filename="big.json"',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
JSON.stringify(items),
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/products/import',
|
||||
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('5000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toProductResponse optional fields', () => {
|
||||
it('includes optional nutrition fields and imageUrl when present', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [
|
||||
makeFakeProduct({
|
||||
densityGPerMl: 1.1,
|
||||
imageUrl: 'https://example.com/img.jpg',
|
||||
deletedAt: new Date().toISOString(),
|
||||
nutrition: {
|
||||
calories: 100,
|
||||
protein: 5,
|
||||
carbs: 10,
|
||||
fat: 2,
|
||||
fiber: 3,
|
||||
sugar: 1,
|
||||
sodium: 50,
|
||||
saturatedFat: 0.5,
|
||||
cholesterol: 10,
|
||||
},
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/products',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const product = res.json().data[0];
|
||||
expect(product.densityGPerMl).toBe(1.1);
|
||||
expect(product.imageUrl).toBe('https://example.com/img.jpg');
|
||||
expect(product.deletedAt).toBeDefined();
|
||||
expect(product.nutrition.fiber).toBe(3);
|
||||
expect(product.nutrition.sugar).toBe(1);
|
||||
expect(product.nutrition.sodium).toBe(50);
|
||||
expect(product.nutrition.saturatedFat).toBe(0.5);
|
||||
expect(product.nutrition.cholesterol).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
308
packages/api/src/modules/products/products.routes.ts
Normal file
308
packages/api/src/modules/products/products.routes.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import multipart from '@fastify/multipart';
|
||||
import {
|
||||
CreateProductSchema,
|
||||
UpdateProductSchema,
|
||||
ProductQuerySchema,
|
||||
ProductResponseSchema,
|
||||
ProductListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { ProductsRepository } from './products.repository.js';
|
||||
import { ProductsService } from './products.service.js';
|
||||
import { BarcodeService } from './barcode.service.js';
|
||||
import { parseCsv, MAX_FILE_SIZE, MAX_ROWS } from './csv-parser.js';
|
||||
|
||||
type AnyProductDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
name: string;
|
||||
brand?: string | null;
|
||||
barcode?: string | null;
|
||||
category: string;
|
||||
servingSize: number;
|
||||
servingUnit: string;
|
||||
densityGPerMl?: number | null;
|
||||
nutrition: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number | null;
|
||||
sugar?: number | null;
|
||||
sodium?: number | null;
|
||||
saturatedFat?: number | null;
|
||||
cholesterol?: number | null;
|
||||
};
|
||||
tags: string[];
|
||||
imageUrl?: string | null;
|
||||
source: string;
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
deletedAt?: string | { toISOString: () => string } | null;
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | { toISOString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
|
||||
function toProductResponse(doc: AnyProductDoc): z.infer<typeof ProductResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
...(doc.brand ? { brand: doc.brand } : {}),
|
||||
...(doc.barcode ? { barcode: doc.barcode } : {}),
|
||||
category: doc.category,
|
||||
servingSize: doc.servingSize,
|
||||
servingUnit: doc.servingUnit,
|
||||
...(doc.densityGPerMl != null ? { densityGPerMl: doc.densityGPerMl } : {}),
|
||||
nutrition: {
|
||||
calories: doc.nutrition.calories,
|
||||
protein: doc.nutrition.protein,
|
||||
carbs: doc.nutrition.carbs,
|
||||
fat: doc.nutrition.fat,
|
||||
...(doc.nutrition.fiber != null ? { fiber: doc.nutrition.fiber } : {}),
|
||||
...(doc.nutrition.sugar != null ? { sugar: doc.nutrition.sugar } : {}),
|
||||
...(doc.nutrition.sodium != null ? { sodium: doc.nutrition.sodium } : {}),
|
||||
...(doc.nutrition.saturatedFat != null ? { saturatedFat: doc.nutrition.saturatedFat } : {}),
|
||||
...(doc.nutrition.cholesterol != null ? { cholesterol: doc.nutrition.cholesterol } : {}),
|
||||
},
|
||||
tags: doc.tags,
|
||||
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
|
||||
source: doc.source,
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
...(doc.deletedAt ? { deletedAt: toIso(doc.deletedAt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
productsRepository: ProductsRepository;
|
||||
productsService: ProductsService;
|
||||
barcodeService: BarcodeService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }),
|
||||
barcodeService: asClass(BarcodeService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/products — list/search
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/products',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: ProductQuerySchema,
|
||||
response: { 200: ProductListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('productsService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toProductResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/products/barcode/:code — barcode lookup
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/products/barcode/:code',
|
||||
schema: {
|
||||
params: householdParams.extend({ code: z.string() }),
|
||||
response: {
|
||||
200: ProductResponseSchema,
|
||||
404: z.object({ found: z.literal(false) }),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const { householdId, code } = request.params;
|
||||
const service = fastify.diContainer.resolve('barcodeService');
|
||||
const result = await service.lookup(householdId, code, request.user.keycloakId);
|
||||
if (!result.found) {
|
||||
return reply.status(404).send({ found: false });
|
||||
}
|
||||
return reply.send(toProductResponse(result.product as AnyProductDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/products/:id — get by id
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/products/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: ProductResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('productsService');
|
||||
const product = await service.getById(request.params.id, request.params.householdId);
|
||||
return reply.send(toProductResponse(product));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/products — create
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/products',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateProductSchema,
|
||||
response: { 201: ProductResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('productsService');
|
||||
const product = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toProductResponse(product));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /api/v1/households/:householdId/products/:id — update
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/products/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdateProductSchema,
|
||||
response: { 200: ProductResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('productsService');
|
||||
const product = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toProductResponse(product));
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /api/v1/households/:householdId/products/:id — soft delete
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/products/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 204: z.undefined() },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('productsService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/products/smart-add — LLM placeholder
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/products/smart-add',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: z.object({ text: z.string().optional() }),
|
||||
response: {
|
||||
200: z.object({ available: z.literal(false), message: z.string() }),
|
||||
},
|
||||
},
|
||||
handler: async (_request, reply) => {
|
||||
return reply.send({
|
||||
available: false,
|
||||
message: 'LLM not configured',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/products/import — bulk CSV/JSON import
|
||||
await fastify.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
|
||||
|
||||
const ImportResponseSchema = z.object({
|
||||
imported: z.number(),
|
||||
skipped: z.number(),
|
||||
errors: z.array(z.object({ row: z.number(), message: z.string() })),
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/products/import',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
response: {
|
||||
200: ImportResponseSchema,
|
||||
400: z.object({ error: z.string() }),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) {
|
||||
return reply.status(400).send({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const content = buffer.toString('utf-8');
|
||||
const contentType = file.mimetype;
|
||||
|
||||
let items: Parameters<ProductsService['importProducts']>[2];
|
||||
let parseErrors: { row: number; message: string }[] = [];
|
||||
|
||||
if (contentType === 'application/json' || file.filename.endsWith('.json')) {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return reply.status(400).send({ error: 'JSON must be an array' });
|
||||
}
|
||||
items = parsed;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: 'Invalid JSON' });
|
||||
}
|
||||
} else {
|
||||
const result = parseCsv(content);
|
||||
items = result.items;
|
||||
parseErrors = result.errors;
|
||||
}
|
||||
|
||||
if (items.length > MAX_ROWS) {
|
||||
return reply.status(400).send({ error: `Maximum ${MAX_ROWS} rows allowed` });
|
||||
}
|
||||
|
||||
const service = fastify.diContainer.resolve('productsService');
|
||||
const result = await service.importProducts(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
items,
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
errors: [...parseErrors, ...result.errors],
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'products-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
297
packages/api/src/modules/products/products.service.test.ts
Normal file
297
packages/api/src/modules/products/products.service.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProductsService } from './products.service.js';
|
||||
import { NotFoundError, ConflictError } from '../../common/errors.js';
|
||||
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||
|
||||
const mockRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByIds: vi.fn(),
|
||||
findByBarcode: vi.fn(),
|
||||
findDuplicate: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
bulkCreate: vi.fn(),
|
||||
};
|
||||
|
||||
function makeProduct(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Chicken Breast',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: ProductSource.MANUAL,
|
||||
createdBy: 'u1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const createData = {
|
||||
name: 'Chicken Breast',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: ProductSource.MANUAL,
|
||||
};
|
||||
|
||||
describe(ProductsService.name, () => {
|
||||
let service: ProductsService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ProductsService({ productsRepository: mockRepo as never });
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRepo.findByHousehold.mockResolvedValue(expected);
|
||||
|
||||
const result = await service.list('hh1', { limit: 20 });
|
||||
|
||||
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns product when found', async () => {
|
||||
const product = makeProduct();
|
||||
mockRepo.findById.mockResolvedValue(product);
|
||||
|
||||
const result = await service.getById('p1', 'hh1');
|
||||
expect(result).toEqual(product);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when product not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates product when no barcode conflict and no duplicate', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue(makeProduct());
|
||||
|
||||
const result = await service.create(createData, 'hh1', 'u1');
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Chicken Breast', householdId: 'hh1', createdBy: 'u1' }),
|
||||
);
|
||||
expect(result._id).toBe('p1');
|
||||
});
|
||||
|
||||
it('does not check barcode when none provided', async () => {
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue(makeProduct());
|
||||
|
||||
await service.create(createData, 'hh1', 'u1');
|
||||
|
||||
expect(mockRepo.findByBarcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when barcode already exists', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(makeProduct({ barcode: '1234567890' }));
|
||||
|
||||
await expect(
|
||||
service.create({ ...createData, barcode: '1234567890' }, 'hh1', 'u1'),
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('throws ConflictError when duplicate name+brand exists', async () => {
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.findDuplicate.mockResolvedValue(makeProduct());
|
||||
|
||||
await expect(service.create(createData, 'hh1', 'u1')).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('uses ProductSource.MANUAL as default source', async () => {
|
||||
const dataWithoutSource = { ...createData };
|
||||
delete (dataWithoutSource as Partial<typeof createData>).source;
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.create.mockResolvedValue(makeProduct());
|
||||
|
||||
await service.create(dataWithoutSource, 'hh1', 'u1');
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: ProductSource.MANUAL }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates product successfully', async () => {
|
||||
const product = makeProduct();
|
||||
mockRepo.findById.mockResolvedValue(product);
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.update.mockResolvedValue({ ...product, name: 'Updated' });
|
||||
|
||||
const result = await service.update('p1', 'hh1', { name: 'Updated' });
|
||||
|
||||
expect(result.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when product does not exist', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('missing', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws ConflictError when barcode belongs to another product', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeProduct());
|
||||
mockRepo.findByBarcode.mockResolvedValue(makeProduct({ _id: 'p2' }));
|
||||
|
||||
await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw barcode conflict when barcode belongs to same product', async () => {
|
||||
const product = makeProduct({ barcode: '1234567890' });
|
||||
mockRepo.findById.mockResolvedValue(product);
|
||||
mockRepo.findByBarcode.mockResolvedValue(product);
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.update.mockResolvedValue(product);
|
||||
|
||||
await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('throws ConflictError when name+brand already taken by another', async () => {
|
||||
const product = makeProduct();
|
||||
mockRepo.findById.mockResolvedValue(product);
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.findDuplicate.mockResolvedValue(makeProduct({ _id: 'p2' }));
|
||||
|
||||
await expect(service.update('p1', 'hh1', { name: 'Chicken Breast' })).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
const product = makeProduct();
|
||||
mockRepo.findById.mockResolvedValue(product);
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('p1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('uses current brand when data.brand is not provided in dedup check', async () => {
|
||||
const product = makeProduct({ brand: 'BrandA' });
|
||||
mockRepo.findById
|
||||
.mockResolvedValueOnce(product) // getById call
|
||||
.mockResolvedValueOnce(product); // second findById call in update
|
||||
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.update.mockResolvedValue(product);
|
||||
|
||||
await service.update('p1', 'hh1', { name: 'New Name' });
|
||||
|
||||
expect(mockRepo.findDuplicate).toHaveBeenCalledWith('hh1', 'New Name', 'BrandA', 'p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('soft-deletes the product', async () => {
|
||||
const product = makeProduct();
|
||||
mockRepo.findById.mockResolvedValue(product);
|
||||
mockRepo.softDelete.mockResolvedValue({ ...product, deletedAt: new Date() });
|
||||
|
||||
await service.delete('p1', 'hh1');
|
||||
|
||||
expect(mockRepo.softDelete).toHaveBeenCalledWith('p1', 'hh1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when product does not exist', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when softDelete returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeProduct());
|
||||
mockRepo.softDelete.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importProducts', () => {
|
||||
it('imports products skipping duplicates', async () => {
|
||||
const { source: _s, ...importData } = createData;
|
||||
const items = [
|
||||
{ ...importData, name: 'Item A' },
|
||||
{ ...importData, name: 'Item B', barcode: '111' },
|
||||
{ ...importData, name: 'Item C' },
|
||||
];
|
||||
// Item B has a barcode collision
|
||||
mockRepo.findByBarcode.mockResolvedValueOnce(makeProduct()); // Item B barcode exists
|
||||
mockRepo.findDuplicate
|
||||
.mockResolvedValueOnce(null) // Item A ok
|
||||
.mockResolvedValueOnce(makeProduct()); // Item C duplicate
|
||||
mockRepo.bulkCreate.mockResolvedValue([]);
|
||||
|
||||
const result = await service.importProducts('hh1', 'u1', items as never);
|
||||
|
||||
expect(result.imported).toBe(1); // Item A
|
||||
expect(result.skipped).toBe(2); // Item B (barcode), Item C (duplicate)
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Item A', source: ProductSource.IMPORT }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call bulkCreate when all items are skipped', async () => {
|
||||
mockRepo.findDuplicate.mockResolvedValue(makeProduct());
|
||||
|
||||
const result = await service.importProducts('hh1', 'u1', [createData]);
|
||||
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(mockRepo.bulkCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects provided source over IMPORT default', async () => {
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.bulkCreate.mockResolvedValue([]);
|
||||
|
||||
await service.importProducts('hh1', 'u1', [
|
||||
{ ...createData, source: ProductSource.BARCODE_LOOKUP },
|
||||
]);
|
||||
|
||||
expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.arrayContaining([expect.objectContaining({ source: ProductSource.BARCODE_LOOKUP })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('records error when repo throws during item processing', async () => {
|
||||
mockRepo.findByBarcode.mockRejectedValue(new Error('DB error'));
|
||||
mockRepo.bulkCreate.mockResolvedValue([]);
|
||||
|
||||
const result = await service.importProducts('hh1', 'u1', [{ ...createData, barcode: '111' }]);
|
||||
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({ row: 1, message: 'Validation error' });
|
||||
});
|
||||
});
|
||||
});
|
||||
134
packages/api/src/modules/products/products.service.ts
Normal file
134
packages/api/src/modules/products/products.service.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import type { ProductsRepository } from './products.repository.js';
|
||||
import type { CreateProductInput, UpdateProductInput, ProductQueryInput } from '@meshitrack/shared';
|
||||
import { ProductSource } from '@meshitrack/shared';
|
||||
import { NotFoundError, ConflictError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
productsRepository: ProductsRepository;
|
||||
}
|
||||
|
||||
export class ProductsService {
|
||||
private readonly productsRepository: ProductsRepository;
|
||||
|
||||
public constructor({ productsRepository }: Deps) {
|
||||
this.productsRepository = productsRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: ProductQueryInput) {
|
||||
return this.productsRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
const product = await this.productsRepository.findById(id, householdId);
|
||||
if (!product) {
|
||||
throw new NotFoundError('Product not found');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
public async create(data: CreateProductInput, householdId: string, createdBy: string) {
|
||||
if (data.barcode) {
|
||||
const existing = await this.productsRepository.findByBarcode(householdId, data.barcode);
|
||||
if (existing) {
|
||||
throw new ConflictError('A product with this barcode already exists in your household');
|
||||
}
|
||||
}
|
||||
|
||||
const duplicate = await this.productsRepository.findDuplicate(
|
||||
householdId,
|
||||
data.name,
|
||||
data.brand,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new ConflictError('A product with the same name and brand already exists');
|
||||
}
|
||||
|
||||
return this.productsRepository.create({
|
||||
...data,
|
||||
householdId,
|
||||
createdBy,
|
||||
source: data.source ?? ProductSource.MANUAL,
|
||||
});
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateProductInput) {
|
||||
await this.getById(id, householdId);
|
||||
|
||||
if (data.barcode) {
|
||||
const existing = await this.productsRepository.findByBarcode(householdId, data.barcode);
|
||||
if (existing && existing._id.toString() !== id) {
|
||||
throw new ConflictError('A product with this barcode already exists in your household');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.name || data.brand !== undefined) {
|
||||
const current = await this.productsRepository.findById(id, householdId);
|
||||
const name = data.name ?? current!.name;
|
||||
const brand = data.brand !== undefined ? data.brand : (current!.brand ?? undefined);
|
||||
|
||||
const duplicate = await this.productsRepository.findDuplicate(householdId, name, brand, id);
|
||||
if (duplicate) {
|
||||
throw new ConflictError('A product with the same name and brand already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.productsRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Product not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
await this.getById(id, householdId);
|
||||
const deleted = await this.productsRepository.softDelete(id, householdId);
|
||||
if (!deleted) throw new NotFoundError('Product not found');
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async importProducts(householdId: string, createdBy: string, items: CreateProductInput[]) {
|
||||
const imported: number[] = [];
|
||||
const skipped: number[] = [];
|
||||
const errors: { row: number; message: string }[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]!;
|
||||
try {
|
||||
if (item.barcode) {
|
||||
const existing = await this.productsRepository.findByBarcode(householdId, item.barcode);
|
||||
if (existing) {
|
||||
skipped.push(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const duplicate = await this.productsRepository.findDuplicate(
|
||||
householdId,
|
||||
item.name,
|
||||
item.brand,
|
||||
);
|
||||
if (duplicate) {
|
||||
skipped.push(i);
|
||||
continue;
|
||||
}
|
||||
imported.push(i);
|
||||
} catch {
|
||||
errors.push({ row: i + 1, message: 'Validation error' });
|
||||
}
|
||||
}
|
||||
|
||||
const toCreate = imported.map((i) => ({
|
||||
...items[i]!,
|
||||
householdId,
|
||||
createdBy,
|
||||
source: items[i]!.source ?? ProductSource.IMPORT,
|
||||
}));
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await this.productsRepository.bulkCreate(householdId, toCreate);
|
||||
}
|
||||
|
||||
return {
|
||||
imported: imported.length,
|
||||
skipped: skipped.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { NutritionCalculatorService } from './nutrition-calculator.service.js';
|
||||
import { NutritionWarning } from '@meshitrack/shared';
|
||||
|
||||
const service = new NutritionCalculatorService();
|
||||
|
||||
function makeProduct(
|
||||
overrides: Partial<{
|
||||
servingSize: number;
|
||||
servingUnit: string;
|
||||
nutrition: Record<string, number>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
servingSize: overrides.servingSize ?? 100,
|
||||
servingUnit: overrides.servingUnit ?? 'g',
|
||||
nutrition: {
|
||||
calories: 200,
|
||||
protein: 20,
|
||||
carbs: 10,
|
||||
fat: 8,
|
||||
...(overrides.nutrition ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(NutritionCalculatorService.name, () => {
|
||||
describe('calculateRecipeNutrition', () => {
|
||||
it('calculates total and per-serving nutrition from one ingredient', () => {
|
||||
const product = makeProduct(); // 200 kcal per 100g
|
||||
const productMap = new Map([['p1', product]]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[{ productId: 'p1', quantity: 200 }], // 200g = 2 servings worth
|
||||
productMap,
|
||||
2, // 2 servings
|
||||
);
|
||||
|
||||
expect(result.totalNutrition.calories).toBe(400);
|
||||
expect(result.perServingNutrition.calories).toBe(200);
|
||||
expect(result.totalNutrition.protein).toBe(40);
|
||||
expect(result.perServingNutrition.protein).toBe(20);
|
||||
});
|
||||
|
||||
it('sums contributions from multiple ingredients', () => {
|
||||
const p1 = makeProduct({ nutrition: { calories: 100, protein: 10, carbs: 5, fat: 4 } });
|
||||
const p2 = makeProduct({ nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 } });
|
||||
const productMap = new Map([
|
||||
['p1', p1],
|
||||
['p2', p2],
|
||||
]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[
|
||||
{ productId: 'p1', quantity: 100 }, // 1× serving
|
||||
{ productId: 'p2', quantity: 100 }, // 1× serving
|
||||
],
|
||||
productMap,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.totalNutrition.calories).toBe(300);
|
||||
expect(result.perServingNutrition.calories).toBe(300);
|
||||
});
|
||||
|
||||
it('uses zero nutrition for unknown product', () => {
|
||||
const productMap = new Map<string, ReturnType<typeof makeProduct>>();
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[{ productId: 'missing', quantity: 100 }],
|
||||
productMap,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.totalNutrition.calories).toBe(0);
|
||||
});
|
||||
|
||||
it('propagates optional nutrients (sodium, fiber, sugar)', () => {
|
||||
const product = makeProduct({
|
||||
nutrition: {
|
||||
calories: 100,
|
||||
protein: 5,
|
||||
carbs: 10,
|
||||
fat: 2,
|
||||
sodium: 800,
|
||||
fiber: 4,
|
||||
sugar: 12,
|
||||
},
|
||||
});
|
||||
const productMap = new Map([['p1', product]]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[{ productId: 'p1', quantity: 100 }],
|
||||
productMap,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.totalNutrition.sodium).toBe(800);
|
||||
expect(result.totalNutrition.fiber).toBe(4);
|
||||
expect(result.totalNutrition.sugar).toBe(12);
|
||||
});
|
||||
|
||||
it('propagates saturatedFat and cholesterol across multiple ingredients', () => {
|
||||
const p1 = makeProduct({
|
||||
nutrition: {
|
||||
calories: 100,
|
||||
protein: 5,
|
||||
carbs: 10,
|
||||
fat: 3,
|
||||
saturatedFat: 1.5,
|
||||
cholesterol: 30,
|
||||
},
|
||||
});
|
||||
const p2 = makeProduct({
|
||||
nutrition: {
|
||||
calories: 150,
|
||||
protein: 8,
|
||||
carbs: 12,
|
||||
fat: 5,
|
||||
saturatedFat: 2.5,
|
||||
cholesterol: 50,
|
||||
},
|
||||
});
|
||||
const productMap = new Map([
|
||||
['p1', p1],
|
||||
['p2', p2],
|
||||
]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[
|
||||
{ productId: 'p1', quantity: 100 },
|
||||
{ productId: 'p2', quantity: 100 },
|
||||
],
|
||||
productMap,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.totalNutrition.saturatedFat).toBe(4);
|
||||
expect(result.totalNutrition.cholesterol).toBe(80);
|
||||
});
|
||||
|
||||
it('handles product with zero servingSize', () => {
|
||||
const product = makeProduct({ servingSize: 0 });
|
||||
const productMap = new Map([['p1', product]]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[{ productId: 'p1', quantity: 100 }],
|
||||
productMap,
|
||||
1,
|
||||
);
|
||||
|
||||
// ratio = 0 when servingSize = 0
|
||||
expect(result.totalNutrition.calories).toBe(0);
|
||||
});
|
||||
|
||||
it('handles zero servings', () => {
|
||||
const product = makeProduct();
|
||||
const productMap = new Map([['p1', product]]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[{ productId: 'p1', quantity: 100 }],
|
||||
productMap,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.perServingNutrition.calories).toBe(0);
|
||||
});
|
||||
|
||||
it('multiplies saturatedFat and cholesterol by ratio', () => {
|
||||
const product = makeProduct({
|
||||
nutrition: {
|
||||
calories: 100,
|
||||
protein: 5,
|
||||
carbs: 10,
|
||||
fat: 3,
|
||||
saturatedFat: 2,
|
||||
cholesterol: 40,
|
||||
},
|
||||
});
|
||||
const productMap = new Map([['p1', product]]);
|
||||
|
||||
const result = service.calculateRecipeNutrition(
|
||||
[{ productId: 'p1', quantity: 200 }], // 2x serving
|
||||
productMap,
|
||||
2,
|
||||
);
|
||||
|
||||
// 2x ratio, then divide by 2 servings = same as per serving
|
||||
expect(result.totalNutrition.saturatedFat).toBe(4);
|
||||
expect(result.totalNutrition.cholesterol).toBe(80);
|
||||
expect(result.perServingNutrition.saturatedFat).toBe(2);
|
||||
expect(result.perServingNutrition.cholesterol).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWarnings', () => {
|
||||
it('flags HIGH_CALORIES when > 800 kcal/serving', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 900,
|
||||
protein: 20,
|
||||
carbs: 50,
|
||||
fat: 30,
|
||||
});
|
||||
expect(warnings).toContain(NutritionWarning.HIGH_CALORIES);
|
||||
});
|
||||
|
||||
it('flags HIGH_SODIUM when > 1500 mg/serving', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 400,
|
||||
protein: 15,
|
||||
carbs: 30,
|
||||
fat: 10,
|
||||
sodium: 1600,
|
||||
});
|
||||
expect(warnings).toContain(NutritionWarning.HIGH_SODIUM);
|
||||
});
|
||||
|
||||
it('flags LOW_PROTEIN when < 10 g/serving', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 300,
|
||||
protein: 5,
|
||||
carbs: 40,
|
||||
fat: 10,
|
||||
});
|
||||
expect(warnings).toContain(NutritionWarning.LOW_PROTEIN);
|
||||
});
|
||||
|
||||
it('flags LOW_FIBER when fiber is present and < 3 g/serving', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 300,
|
||||
protein: 15,
|
||||
carbs: 40,
|
||||
fat: 10,
|
||||
fiber: 1,
|
||||
});
|
||||
expect(warnings).toContain(NutritionWarning.LOW_FIBER);
|
||||
});
|
||||
|
||||
it('does not flag LOW_FIBER when fiber is absent', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 300,
|
||||
protein: 15,
|
||||
carbs: 40,
|
||||
fat: 10,
|
||||
});
|
||||
expect(warnings).not.toContain(NutritionWarning.LOW_FIBER);
|
||||
});
|
||||
|
||||
it('returns no warnings for a healthy meal', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 450,
|
||||
protein: 30,
|
||||
carbs: 40,
|
||||
fat: 12,
|
||||
sodium: 600,
|
||||
fiber: 8,
|
||||
sugar: 10,
|
||||
saturatedFat: 4,
|
||||
cholesterol: 80,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can return multiple warnings', () => {
|
||||
const warnings = service.generateWarnings({
|
||||
calories: 900,
|
||||
protein: 5,
|
||||
carbs: 80,
|
||||
fat: 40,
|
||||
sodium: 2000,
|
||||
sugar: 30,
|
||||
saturatedFat: 20,
|
||||
cholesterol: 250,
|
||||
fiber: 1,
|
||||
});
|
||||
expect(warnings.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
packages/api/src/modules/recipes/nutrition-calculator.service.ts
Normal file
127
packages/api/src/modules/recipes/nutrition-calculator.service.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { NutritionInfo, RecipeIngredient } from '@meshitrack/shared';
|
||||
import { NutritionWarning } from '@meshitrack/shared';
|
||||
|
||||
export interface ProductLike {
|
||||
servingSize: number;
|
||||
servingUnit: string;
|
||||
nutrition: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number | null;
|
||||
sugar?: number | null;
|
||||
sodium?: number | null;
|
||||
saturatedFat?: number | null;
|
||||
cholesterol?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecipeNutritionResult {
|
||||
totalNutrition: NutritionInfo;
|
||||
perServingNutrition: NutritionInfo;
|
||||
ingredientContributions: NutritionInfo[];
|
||||
}
|
||||
|
||||
const WARNING_THRESHOLDS: Array<{
|
||||
warning: NutritionWarning;
|
||||
check: (n: NutritionInfo) => boolean;
|
||||
}> = [
|
||||
{ warning: NutritionWarning.HIGH_CALORIES, check: (n) => n.calories > 800 },
|
||||
{ warning: NutritionWarning.HIGH_SODIUM, check: (n) => (n.sodium ?? 0) > 1500 },
|
||||
{ warning: NutritionWarning.HIGH_SUGAR, check: (n) => (n.sugar ?? 0) > 25 },
|
||||
{ warning: NutritionWarning.HIGH_SATURATED_FAT, check: (n) => (n.saturatedFat ?? 0) > 13 },
|
||||
{ warning: NutritionWarning.LOW_PROTEIN, check: (n) => n.protein < 10 },
|
||||
{ warning: NutritionWarning.LOW_FIBER, check: (n) => (n.fiber ?? 999) < 3 },
|
||||
{ warning: NutritionWarning.HIGH_CHOLESTEROL, check: (n) => (n.cholesterol ?? 0) > 200 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Calculates nutrition for a recipe from its persisted (metric) ingredients and the
|
||||
* corresponding product documents. All ingredient units must already be RecipeUnit
|
||||
* (conversion is handled before calling this service).
|
||||
*/
|
||||
export class NutritionCalculatorService {
|
||||
/**
|
||||
* Calculate total and per-serving nutrition for a recipe.
|
||||
*
|
||||
* For each ingredient:
|
||||
* ratio = ingredient.quantity / product.servingSize
|
||||
* contribution = product.nutrition * ratio
|
||||
* totalNutrition = sum of contributions
|
||||
* perServingNutrition = totalNutrition / servings
|
||||
*/
|
||||
public calculateRecipeNutrition(
|
||||
ingredients: Array<Pick<RecipeIngredient, 'productId' | 'quantity'>>,
|
||||
productMap: Map<string, ProductLike>,
|
||||
servings: number,
|
||||
): RecipeNutritionResult {
|
||||
const ingredientContributions: NutritionInfo[] = [];
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
const product = productMap.get(ingredient.productId);
|
||||
if (!product) {
|
||||
ingredientContributions.push(zeroNutrition());
|
||||
continue;
|
||||
}
|
||||
|
||||
const ratio = product.servingSize > 0 ? ingredient.quantity / product.servingSize : 0;
|
||||
ingredientContributions.push(multiplyNutrition(product.nutrition, ratio));
|
||||
}
|
||||
|
||||
const totalNutrition = sumNutrition(ingredientContributions);
|
||||
const perServingNutrition = multiplyNutrition(totalNutrition, servings > 0 ? 1 / servings : 0);
|
||||
|
||||
return { totalNutrition, perServingNutrition, ingredientContributions };
|
||||
}
|
||||
|
||||
public generateWarnings(perServingNutrition: NutritionInfo): NutritionWarning[] {
|
||||
return WARNING_THRESHOLDS.filter(({ check }) => check(perServingNutrition)).map(
|
||||
({ warning }) => warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function zeroNutrition(): NutritionInfo {
|
||||
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
|
||||
}
|
||||
|
||||
function multiplyNutrition(n: ProductLike['nutrition'], factor: number): NutritionInfo {
|
||||
return {
|
||||
calories: round(n.calories * factor),
|
||||
protein: round(n.protein * factor),
|
||||
carbs: round(n.carbs * factor),
|
||||
fat: round(n.fat * factor),
|
||||
...(n.fiber != null ? { fiber: round(n.fiber * factor) } : {}),
|
||||
...(n.sugar != null ? { sugar: round(n.sugar * factor) } : {}),
|
||||
...(n.sodium != null ? { sodium: round(n.sodium * factor) } : {}),
|
||||
...(n.saturatedFat != null ? { saturatedFat: round(n.saturatedFat * factor) } : {}),
|
||||
...(n.cholesterol != null ? { cholesterol: round(n.cholesterol * factor) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sumNutrition(items: NutritionInfo[]): NutritionInfo {
|
||||
const total = zeroNutrition();
|
||||
for (const item of items) {
|
||||
total.calories += item.calories;
|
||||
total.protein += item.protein;
|
||||
total.carbs += item.carbs;
|
||||
total.fat += item.fat;
|
||||
if (item.fiber != null) total.fiber = round((total.fiber ?? 0) + item.fiber);
|
||||
if (item.sugar != null) total.sugar = round((total.sugar ?? 0) + item.sugar);
|
||||
if (item.sodium != null) total.sodium = round((total.sodium ?? 0) + item.sodium);
|
||||
if (item.saturatedFat != null)
|
||||
total.saturatedFat = round((total.saturatedFat ?? 0) + item.saturatedFat);
|
||||
if (item.cholesterol != null)
|
||||
total.cholesterol = round((total.cholesterol ?? 0) + item.cholesterol);
|
||||
}
|
||||
total.calories = round(total.calories);
|
||||
total.protein = round(total.protein);
|
||||
total.carbs = round(total.carbs);
|
||||
total.fat = round(total.fat);
|
||||
return total;
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
217
packages/api/src/modules/recipes/recipes.repository.test.ts
Normal file
217
packages/api/src/modules/recipes/recipes.repository.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/recipe.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 = () => ({
|
||||
exec: mockFindOneAndUpdate,
|
||||
});
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
return { RecipeModel: FakeModel };
|
||||
});
|
||||
|
||||
import { RecipesRepository } from './recipes.repository.js';
|
||||
|
||||
describe(RecipesRepository.name, () => {
|
||||
let repo: RecipesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new RecipesRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated list without filters', async () => {
|
||||
const items = [{ _id: { toString: () => 'r1' }, name: 'Recipe 1' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns hasMore when more items exist', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({
|
||||
_id: { toString: () => `r${i}` },
|
||||
name: `Recipe ${i}`,
|
||||
}));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies text search filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { q: 'pasta', limit: 20 });
|
||||
// No error thrown means the $text filter was applied
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies cuisine filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { cuisine: 'Italian', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies isFavorite filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { isFavorite: true, limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies maxCalories filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { maxCalories: 500, limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies tags filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { tags: 'vegetarian,quick', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips empty tags', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { tags: ',', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies cursor for pagination', async () => {
|
||||
const cursor = Buffer.from('abc123').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns recipe when found', async () => {
|
||||
const recipe = { _id: 'r1', householdId: 'hh1', name: 'Recipe' };
|
||||
mockFindOne.mockResolvedValue(recipe);
|
||||
const result = await repo.findById('r1', 'hh1');
|
||||
expect(result).toEqual(recipe);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
const result = await repo.findById('missing', 'hh1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProductId', () => {
|
||||
it('returns recipes containing the product', async () => {
|
||||
const items = [{ _id: { toString: () => 'r1' }, name: 'Recipe' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByProductId('hh1', 'p1', { limit: 20 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('supports cursor pagination', async () => {
|
||||
const cursor = Buffer.from('r1').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
const result = await repo.findByProductId('hh1', 'p1', { cursor, limit: 20 });
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults limit to 20', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const result = await repo.findByProductId('hh1', 'p1', {});
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllByProductId', () => {
|
||||
it('returns all recipes with the product', async () => {
|
||||
const recipes = [{ _id: 'r1' }, { _id: 'r2' }];
|
||||
mockFind.mockResolvedValue(recipes);
|
||||
const result = await repo.findAllByProductId('hh1', 'p1');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns the recipe', async () => {
|
||||
const data = { name: 'New Recipe', servings: 2, steps: [], tags: [], isFavorite: false };
|
||||
const computed = {
|
||||
ingredients: [],
|
||||
totalNutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
perServingNutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const result = await repo.create(data, computed, 'hh1', 'user-1');
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ name: 'New Recipe' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns the recipe', async () => {
|
||||
const updated = { _id: 'r1', name: 'Updated' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const result = await repo.update('r1', 'hh1', { name: 'Updated' });
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('applies computed fields when provided', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'r1' });
|
||||
await repo.update(
|
||||
'r1',
|
||||
'hh1',
|
||||
{},
|
||||
{
|
||||
totalNutrition: { calories: 100, protein: 10, carbs: 5, fat: 3 },
|
||||
perServingNutrition: { calories: 100, protein: 10, carbs: 5, fat: 3 },
|
||||
warnings: [],
|
||||
},
|
||||
);
|
||||
expect(mockFindOneAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('sets deletedAt and returns', async () => {
|
||||
const deleted = { _id: 'r1', deletedAt: new Date() };
|
||||
mockFindOneAndUpdate.mockResolvedValue(deleted);
|
||||
const result = await repo.softDelete('r1', 'hh1');
|
||||
expect(result).toEqual(deleted);
|
||||
});
|
||||
});
|
||||
});
|
||||
141
packages/api/src/modules/recipes/recipes.repository.ts
Normal file
141
packages/api/src/modules/recipes/recipes.repository.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { RecipeModel } from '../../schemas/recipe.schema.js';
|
||||
import type { CreateRecipeInput, UpdateRecipeInput, RecipeQueryInput } from '@meshitrack/shared';
|
||||
import type { RecipeIngredient as StoredIngredient } from '@meshitrack/shared';
|
||||
|
||||
interface NutritionInfo {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
sodium?: number;
|
||||
saturatedFat?: number;
|
||||
cholesterol?: number;
|
||||
}
|
||||
|
||||
interface ComputedFields {
|
||||
ingredients: StoredIngredient[];
|
||||
totalNutrition: NutritionInfo;
|
||||
perServingNutrition: NutritionInfo;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class RecipesRepository {
|
||||
public async findByHousehold(householdId: string, query: RecipeQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId, deletedAt: { $exists: false } };
|
||||
|
||||
if (query.q) filter['$text'] = { $search: query.q };
|
||||
if (query.cuisine) filter['cuisine'] = { $regex: query.cuisine, $options: 'i' };
|
||||
if (query.isFavorite !== undefined) filter['isFavorite'] = query.isFavorite;
|
||||
if (query.maxCalories !== undefined)
|
||||
filter['perServingNutrition.calories'] = { $lte: query.maxCalories };
|
||||
|
||||
if (query.tags) {
|
||||
const tagList = query.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (tagList.length > 0) filter['tags'] = { $all: tagList };
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await RecipeModel.find(filter)
|
||||
.sort({ _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 RecipeModel.findOne({ _id: id, householdId, deletedAt: { $exists: false } })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async findByProductId(
|
||||
householdId: string,
|
||||
productId: string,
|
||||
query: { cursor?: string; limit?: number },
|
||||
) {
|
||||
const filter: Record<string, unknown> = {
|
||||
householdId,
|
||||
'ingredients.productId': productId,
|
||||
deletedAt: { $exists: false },
|
||||
};
|
||||
|
||||
const limit = query.limit ?? 20;
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const items = await RecipeModel.find(filter)
|
||||
.sort({ _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 findAllByProductId(householdId: string, productId: string) {
|
||||
return RecipeModel.find({
|
||||
householdId,
|
||||
'ingredients.productId': productId,
|
||||
deletedAt: { $exists: false },
|
||||
})
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async create(
|
||||
data: Omit<CreateRecipeInput, 'ingredients'>,
|
||||
computed: ComputedFields,
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
) {
|
||||
const recipe = new RecipeModel({ ...data, ...computed, householdId, createdBy });
|
||||
const saved = await recipe.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: Partial<UpdateRecipeInput>,
|
||||
computed?: Partial<ComputedFields>,
|
||||
) {
|
||||
const update: Record<string, unknown> = { ...data };
|
||||
if (computed) Object.assign(update, computed);
|
||||
return RecipeModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||
{ $set: update },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string) {
|
||||
return RecipeModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||
{ $set: { deletedAt: new Date() } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
471
packages/api/src/modules/recipes/recipes.routes.test.ts
Normal file
471
packages/api/src/modules/recipes/recipes.routes.test.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
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,
|
||||
mockFindByProductId,
|
||||
mockFindAllByProductId,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockSoftDelete,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockFindByProductId: vi.fn(),
|
||||
mockFindAllByProductId: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockSoftDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockFindByIds } = vi.hoisted(() => ({
|
||||
mockFindByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./recipes.repository.js', () => ({
|
||||
RecipesRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findByProductId = mockFindByProductId;
|
||||
findAllByProductId = mockFindAllByProductId;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
softDelete = mockSoftDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findByIds = mockFindByIds;
|
||||
findById = 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 recipesRoutes from './recipes.routes.js';
|
||||
|
||||
const nutrition = { calories: 200, protein: 20, carbs: 10, fat: 8 };
|
||||
|
||||
function makeProduct(id = 'p1') {
|
||||
return {
|
||||
_id: id,
|
||||
householdId: 'hh1',
|
||||
servingSize: 100,
|
||||
servingUnit: 'g',
|
||||
nutrition,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRecipe(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'recipe-1',
|
||||
householdId: 'hh1',
|
||||
name: 'Grilled Chicken',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken Breast',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
nutritionContribution: nutrition,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Grill the chicken.' }],
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
totalNutrition: nutrition,
|
||||
perServingNutrition: nutrition,
|
||||
warnings: [],
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('recipes.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(recipesRoutes);
|
||||
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 /api/v1/households/:householdId/recipes', () => {
|
||||
it('returns paginated list', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [makeRecipe()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe('Grilled Chicken');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
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/recipes',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/recipes/:id', () => {
|
||||
it('returns recipe when found', async () => {
|
||||
mockFindById.mockResolvedValue(makeRecipe());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Grilled Chicken');
|
||||
});
|
||||
|
||||
it('returns recipe with all optional fields', async () => {
|
||||
mockFindById.mockResolvedValue(
|
||||
makeRecipe({
|
||||
description: 'A delicious dish',
|
||||
prepTime: 10,
|
||||
cookTime: 20,
|
||||
totalTime: 30,
|
||||
cuisine: 'Italian',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/recipe',
|
||||
importedAt: new Date('2024-01-01'),
|
||||
},
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken Breast',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
originalQuantity: 7,
|
||||
originalUnit: 'oz',
|
||||
preparation: 'diced',
|
||||
isOptional: false,
|
||||
nutritionContribution: nutrition,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Prep.', duration: 5, tip: 'Use sharp knife.' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.description).toBe('A delicious dish');
|
||||
expect(body.prepTime).toBe(10);
|
||||
expect(body.cookTime).toBe(20);
|
||||
expect(body.totalTime).toBe(30);
|
||||
expect(body.cuisine).toBe('Italian');
|
||||
expect(body.imageUrl).toBe('https://example.com/image.jpg');
|
||||
expect(body.source.type).toBe('url');
|
||||
expect(body.source.url).toBe('https://example.com/recipe');
|
||||
expect(body.source.importedAt).toBeDefined();
|
||||
expect(body.ingredients[0].originalQuantity).toBe(7);
|
||||
expect(body.ingredients[0].originalUnit).toBe('oz');
|
||||
expect(body.ingredients[0].preparation).toBe('diced');
|
||||
expect(body.steps[0].duration).toBe(5);
|
||||
expect(body.steps[0].tip).toBe('Use sharp knife.');
|
||||
});
|
||||
|
||||
it('returns recipe with source but no url or importedAt', async () => {
|
||||
mockFindById.mockResolvedValue(
|
||||
makeRecipe({
|
||||
source: { type: 'manual' },
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.source.type).toBe('manual');
|
||||
expect(body.source.url).toBeUndefined();
|
||||
expect(body.source.importedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns recipe with source.importedAt as string', async () => {
|
||||
mockFindById.mockResolvedValue(
|
||||
makeRecipe({
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com',
|
||||
importedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().source.importedAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns 404 when not found', async () => {
|
||||
mockFindById.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes/missing',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/recipes', () => {
|
||||
it('creates a recipe with metric ingredients', async () => {
|
||||
mockFindByIds.mockResolvedValue([makeProduct()]);
|
||||
mockCreate.mockResolvedValue(makeRecipe());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/recipes',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Grilled Chicken',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken Breast',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Grill the chicken.' }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().name).toBe('Grilled Chicken');
|
||||
});
|
||||
|
||||
it('rejects missing name', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/recipes',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ servings: 2, ingredients: [], steps: [] }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/recipes/:id', () => {
|
||||
it('updates a recipe name', async () => {
|
||||
const updated = makeRecipe({ name: 'Updated Recipe' });
|
||||
mockFindById.mockResolvedValue(makeRecipe());
|
||||
mockUpdate.mockResolvedValue(updated);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Updated Recipe' }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated Recipe');
|
||||
});
|
||||
|
||||
it('updates recipe with new ingredients and recalculates', async () => {
|
||||
const updated = makeRecipe({ name: 'Grilled Chicken' });
|
||||
mockFindById.mockResolvedValue(makeRecipe());
|
||||
mockFindByIds.mockResolvedValue([makeProduct()]);
|
||||
mockUpdate.mockResolvedValue(updated);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken Breast',
|
||||
quantity: 300,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/recipes/:id', () => {
|
||||
it('soft-deletes a recipe', async () => {
|
||||
mockFindById.mockResolvedValue(makeRecipe());
|
||||
mockSoftDelete.mockResolvedValue(makeRecipe());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/recipes/:id/scale', () => {
|
||||
it('returns scaled recipe preview', async () => {
|
||||
mockFindById.mockResolvedValue(makeRecipe());
|
||||
mockFindByIds.mockResolvedValue([makeProduct()]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/recipes/recipe-1/scale',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ targetServings: 4 }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.servings).toBe(4);
|
||||
expect(body.ingredients[0].quantity).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/recipes/import-text', () => {
|
||||
it('returns available:false with NoOp provider', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/recipes/import-text',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'Some recipe text' }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/recipes/import-url', () => {
|
||||
it('returns available:false with NoOp provider', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/recipes/import-url',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url: 'https://example.com/recipe' }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/recipes/by-product/:productId', () => {
|
||||
it('returns recipes using a product', async () => {
|
||||
mockFindByProductId.mockResolvedValue({
|
||||
data: [makeRecipe()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/recipes/by-product/p1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
353
packages/api/src/modules/recipes/recipes.routes.ts
Normal file
353
packages/api/src/modules/recipes/recipes.routes.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, asValue, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateRecipeSchema,
|
||||
UpdateRecipeSchema,
|
||||
RecipeQuerySchema,
|
||||
ScaleRecipeSchema,
|
||||
ImportRecipeTextSchema,
|
||||
ImportRecipeUrlSchema,
|
||||
RecipeResponseSchema,
|
||||
RecipeListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { RecipesRepository } from './recipes.repository.js';
|
||||
import { RecipesService } from './recipes.service.js';
|
||||
import { ProductsRepository } from '../products/products.repository.js';
|
||||
import { NoOpLlmProvider } from '../llm/no-op-llm.provider.js';
|
||||
import type { NutritionInfo } from '@meshitrack/shared';
|
||||
|
||||
type NullableNutritionInfo = {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number | null;
|
||||
sugar?: number | null;
|
||||
sodium?: number | null;
|
||||
saturatedFat?: number | null;
|
||||
cholesterol?: number | null;
|
||||
};
|
||||
|
||||
type AnyIngredient = {
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
originalQuantity?: number | null;
|
||||
originalUnit?: string | null;
|
||||
preparation?: string | null;
|
||||
isOptional: boolean;
|
||||
nutritionContribution: NullableNutritionInfo;
|
||||
};
|
||||
|
||||
type AnyRecipeDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
servings: number;
|
||||
prepTime?: number | null;
|
||||
cookTime?: number | null;
|
||||
totalTime?: number | null;
|
||||
ingredients: AnyIngredient[];
|
||||
steps: Array<{
|
||||
order: number;
|
||||
instruction: string;
|
||||
duration?: number | null;
|
||||
tip?: string | null;
|
||||
}>;
|
||||
tags: string[];
|
||||
cuisine?: string | null;
|
||||
imageUrl?: string | null;
|
||||
source?: { type: string; url?: string | null; importedAt?: Date | string | null } | null;
|
||||
totalNutrition: NullableNutritionInfo;
|
||||
perServingNutrition: NullableNutritionInfo;
|
||||
warnings: string[];
|
||||
isFavorite: boolean;
|
||||
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 stripNullNutrition(n: NullableNutritionInfo): NutritionInfo {
|
||||
return {
|
||||
calories: n.calories,
|
||||
protein: n.protein,
|
||||
carbs: n.carbs,
|
||||
fat: n.fat,
|
||||
...(n.fiber != null ? { fiber: n.fiber } : {}),
|
||||
...(n.sugar != null ? { sugar: n.sugar } : {}),
|
||||
...(n.sodium != null ? { sodium: n.sodium } : {}),
|
||||
...(n.saturatedFat != null ? { saturatedFat: n.saturatedFat } : {}),
|
||||
...(n.cholesterol != null ? { cholesterol: n.cholesterol } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toRecipeResponse(doc: AnyRecipeDoc): z.infer<typeof RecipeResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
...(doc.description ? { description: doc.description } : {}),
|
||||
servings: doc.servings,
|
||||
...(doc.prepTime != null ? { prepTime: doc.prepTime } : {}),
|
||||
...(doc.cookTime != null ? { cookTime: doc.cookTime } : {}),
|
||||
...(doc.totalTime != null ? { totalTime: doc.totalTime } : {}),
|
||||
ingredients: doc.ingredients.map((ing) => ({
|
||||
productId: ing.productId,
|
||||
productName: ing.productName,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit as 'g' | 'ml' | 'piece' | 'slice',
|
||||
...(ing.originalQuantity != null ? { originalQuantity: ing.originalQuantity } : {}),
|
||||
...(ing.originalUnit ? { originalUnit: ing.originalUnit as never } : {}),
|
||||
...(ing.preparation ? { preparation: ing.preparation } : {}),
|
||||
isOptional: ing.isOptional,
|
||||
nutritionContribution: stripNullNutrition(ing.nutritionContribution),
|
||||
})),
|
||||
steps: doc.steps.map((s) => ({
|
||||
order: s.order,
|
||||
instruction: s.instruction,
|
||||
...(s.duration != null ? { duration: s.duration } : {}),
|
||||
...(s.tip ? { tip: s.tip } : {}),
|
||||
})),
|
||||
tags: doc.tags,
|
||||
...(doc.cuisine ? { cuisine: doc.cuisine } : {}),
|
||||
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
|
||||
...(doc.source
|
||||
? {
|
||||
source: {
|
||||
type: doc.source.type as 'manual' | 'url' | 'llm_import' | 'text_import',
|
||||
...(doc.source.url ? { url: doc.source.url } : {}),
|
||||
...(doc.source.importedAt
|
||||
? {
|
||||
importedAt:
|
||||
typeof doc.source.importedAt === 'string'
|
||||
? doc.source.importedAt
|
||||
: (doc.source.importedAt as Date).toISOString(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
totalNutrition: stripNullNutrition(doc.totalNutrition),
|
||||
perServingNutrition: stripNullNutrition(doc.perServingNutrition),
|
||||
warnings: doc.warnings as never,
|
||||
isFavorite: doc.isFavorite,
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
recipesRepository: RecipesRepository;
|
||||
productsRepository: ProductsRepository;
|
||||
recipesService: RecipesService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
recipesRepository: asClass(RecipesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
recipesService: asClass(RecipesService, { lifetime: Lifetime.SINGLETON }),
|
||||
llmProvider: asValue(new NoOpLlmProvider()),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/recipes — list/search
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/recipes',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: RecipeQuerySchema,
|
||||
response: { 200: RecipeListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toRecipeResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/recipes/:id — get by id
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/recipes/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: RecipeResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const recipe = await service.getById(request.params.id, request.params.householdId);
|
||||
return reply.send(toRecipeResponse(recipe));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/recipes — create
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/recipes',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateRecipeSchema,
|
||||
response: { 201: RecipeResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const recipe = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toRecipeResponse(recipe));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /api/v1/households/:householdId/recipes/:id — update
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/recipes/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdateRecipeSchema,
|
||||
response: { 200: RecipeResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const recipe = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toRecipeResponse(recipe));
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /api/v1/households/:householdId/recipes/:id — soft delete
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/recipes/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 204: z.undefined() },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/recipes/:id/scale — scale preview
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/recipes/:id/scale',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: ScaleRecipeSchema,
|
||||
response: { 200: RecipeResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const scaled = await service.scale(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toRecipeResponse(scaled as AnyRecipeDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/recipes/import-text — LLM import from text
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/recipes/import-text',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: ImportRecipeTextSchema,
|
||||
response: {
|
||||
200: z.union([
|
||||
z.object({ available: z.literal(false) }),
|
||||
z.object({ available: z.literal(true), draft: z.unknown() }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const result = await service.importFromText(request.body.text, request.params.householdId);
|
||||
return reply.send(result);
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/recipes/import-url — LLM import from URL
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/recipes/import-url',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: ImportRecipeUrlSchema,
|
||||
response: {
|
||||
200: z.union([
|
||||
z.object({ available: z.literal(false) }),
|
||||
z.object({ available: z.literal(true), draft: z.unknown() }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const result = await service.importFromUrl(request.body.url, request.params.householdId);
|
||||
return reply.send(result);
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/recipes/by-product/:productId
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/recipes/by-product/:productId',
|
||||
schema: {
|
||||
params: householdParams.extend({ productId: z.string() }),
|
||||
querystring: z.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
}),
|
||||
response: { 200: RecipeListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('recipesService');
|
||||
const result = await service.findByProduct(
|
||||
request.params.householdId,
|
||||
request.params.productId,
|
||||
request.query,
|
||||
);
|
||||
return reply.send({
|
||||
data: result.data.map(toRecipeResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'recipes-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
344
packages/api/src/modules/recipes/recipes.service.test.ts
Normal file
344
packages/api/src/modules/recipes/recipes.service.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RecipesService } from './recipes.service.js';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
|
||||
const makeProduct = (id: string, servingUnit = 'g', servingSize = 100) => ({
|
||||
_id: { toString: () => id },
|
||||
householdId: 'hh1',
|
||||
name: 'Test Product',
|
||||
servingSize,
|
||||
servingUnit,
|
||||
densityGPerMl: undefined as number | undefined,
|
||||
nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 },
|
||||
});
|
||||
|
||||
const makeRecipe = (id = 'recipe-1') => ({
|
||||
_id: { toString: () => id },
|
||||
householdId: 'hh1',
|
||||
name: 'Test Recipe',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
nutritionContribution: { calories: 400, protein: 40, carbs: 0, fat: 16 },
|
||||
},
|
||||
],
|
||||
steps: [],
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
totalNutrition: { calories: 400, protein: 40, carbs: 0, fat: 16 },
|
||||
perServingNutrition: { calories: 200, protein: 20, carbs: 0, fat: 8 },
|
||||
warnings: [],
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
describe(RecipesService.name, () => {
|
||||
const mockRecipesRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByProductId: vi.fn(),
|
||||
findAllByProductId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIds: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLlmProvider = {
|
||||
extractNutrition: vi.fn(),
|
||||
parseRecipe: vi.fn(),
|
||||
parseRecipeFromUrl: vi.fn(),
|
||||
parseReceipt: vi.fn(),
|
||||
suggestMealPlan: vi.fn(),
|
||||
parseNaturalLanguage: vi.fn(),
|
||||
};
|
||||
|
||||
let service: RecipesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new RecipesService({
|
||||
recipesRepository: mockRecipesRepo as never,
|
||||
productsRepository: mockProductsRepo as never,
|
||||
llmProvider: mockLlmProvider as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue(expected);
|
||||
|
||||
const result = await service.list('hh1', { limit: 20 });
|
||||
expect(mockRecipesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns recipe when found', async () => {
|
||||
const recipe = makeRecipe();
|
||||
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||
|
||||
const result = await service.getById('recipe-1', 'hh1');
|
||||
expect(result).toEqual(recipe);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockRecipesRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('normalizes metric ingredients and calculates nutrition', async () => {
|
||||
const product = makeProduct('p1');
|
||||
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||
mockRecipesRepo.create.mockResolvedValue(makeRecipe());
|
||||
|
||||
await service.create(
|
||||
{
|
||||
name: 'Test',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [],
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [_, computed] = mockRecipesRepo.create.mock.calls[0]!;
|
||||
expect(computed.totalNutrition.calories).toBe(400); // 200g = 2× of 100g serving (200 kcal each)
|
||||
expect(computed.perServingNutrition.calories).toBe(200);
|
||||
});
|
||||
|
||||
it('throws BadRequestError for missing density on cup → g conversion', async () => {
|
||||
const product = makeProduct('p1', 'g'); // g product, no density
|
||||
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
name: 'Test',
|
||||
servings: 1,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Sugar',
|
||||
quantity: 1,
|
||||
unit: 'cup',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [],
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for unknown product', async () => {
|
||||
mockProductsRepo.findByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
name: 'Test',
|
||||
servings: 1,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'unknown',
|
||||
productName: 'X',
|
||||
quantity: 100,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [],
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('soft-deletes recipe', async () => {
|
||||
const recipe = makeRecipe();
|
||||
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||
mockRecipesRepo.softDelete.mockResolvedValue(recipe);
|
||||
|
||||
const result = await service.delete('recipe-1', 'hh1');
|
||||
expect(mockRecipesRepo.softDelete).toHaveBeenCalledWith('recipe-1', 'hh1');
|
||||
expect(result).toEqual(recipe);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockRecipesRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when softDelete returns null', async () => {
|
||||
mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
|
||||
mockRecipesRepo.softDelete.mockResolvedValue(null);
|
||||
await expect(service.delete('recipe-1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale', () => {
|
||||
it('returns scaled ingredient quantities and recalculated nutrition', async () => {
|
||||
const recipe = makeRecipe();
|
||||
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||
mockProductsRepo.findByIds.mockResolvedValue([makeProduct('p1')]);
|
||||
|
||||
const result = await service.scale('recipe-1', 'hh1', { targetServings: 4 });
|
||||
|
||||
expect(result.servings).toBe(4);
|
||||
// 200g × (4/2) = 400g
|
||||
expect(result.ingredients[0]!.quantity).toBe(400);
|
||||
expect(result.totalNutrition.calories).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromText', () => {
|
||||
it('returns available:false when LLM returns null', async () => {
|
||||
mockLlmProvider.parseRecipe.mockResolvedValue(null);
|
||||
const result = await service.importFromText('some text', 'hh1');
|
||||
expect(result).toEqual({ available: false });
|
||||
});
|
||||
|
||||
it('returns draft when LLM returns a recipe', async () => {
|
||||
const draft = { name: 'Pasta', servings: 4, ingredients: [], steps: [] };
|
||||
mockLlmProvider.parseRecipe.mockResolvedValue(draft);
|
||||
const result = await service.importFromText('pasta recipe', 'hh1');
|
||||
expect(result).toEqual({ available: true, draft });
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromUrl', () => {
|
||||
it('returns available:false when LLM returns null', async () => {
|
||||
mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(null);
|
||||
const result = await service.importFromUrl('https://example.com/recipe', 'hh1');
|
||||
expect(result).toEqual({ available: false });
|
||||
});
|
||||
|
||||
it('returns draft when LLM returns a recipe', async () => {
|
||||
const draft = { name: 'Soup', servings: 2, ingredients: [], steps: [] };
|
||||
mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(draft);
|
||||
const result = await service.importFromUrl('https://example.com', 'hh1');
|
||||
expect(result).toEqual({ available: true, draft });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates metadata without recalculating if no ingredients/servings changed', async () => {
|
||||
const recipe = makeRecipe();
|
||||
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||
mockRecipesRepo.update.mockResolvedValue({ ...recipe, name: 'Renamed' });
|
||||
|
||||
const result = await service.update('recipe-1', 'hh1', { name: 'Renamed' });
|
||||
expect(result.name).toBe('Renamed');
|
||||
expect(mockProductsRepo.findByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recalculates nutrition when ingredients change', async () => {
|
||||
const recipe = makeRecipe();
|
||||
const product = makeProduct('p1');
|
||||
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||
mockRecipesRepo.update.mockResolvedValue(recipe);
|
||||
|
||||
await service.update('recipe-1', 'hh1', {
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Chicken',
|
||||
quantity: 300,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockProductsRepo.findByIds).toHaveBeenCalled();
|
||||
expect(mockRecipesRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recalculates nutrition when only servings change', async () => {
|
||||
const recipe = makeRecipe();
|
||||
const product = makeProduct('p1');
|
||||
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||
mockRecipesRepo.update.mockResolvedValue(recipe);
|
||||
|
||||
await service.update('recipe-1', 'hh1', { servings: 4 });
|
||||
|
||||
expect(mockProductsRepo.findByIds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
|
||||
mockRecipesRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('recipe-1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProduct', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRecipesRepo.findByProductId.mockResolvedValue(expected);
|
||||
|
||||
const result = await service.findByProduct('hh1', 'p1', { limit: 20 });
|
||||
expect(mockRecipesRepo.findByProductId).toHaveBeenCalledWith('hh1', 'p1', { limit: 20 });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recalculateForProduct', () => {
|
||||
it('recalculates all recipes containing the product', async () => {
|
||||
const recipe = makeRecipe();
|
||||
const product = makeProduct('p1');
|
||||
mockRecipesRepo.findAllByProductId.mockResolvedValue([recipe]);
|
||||
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||
mockRecipesRepo.update.mockResolvedValue(recipe);
|
||||
|
||||
await service.recalculateForProduct('hh1', 'p1');
|
||||
|
||||
expect(mockRecipesRepo.findAllByProductId).toHaveBeenCalledWith('hh1', 'p1');
|
||||
expect(mockRecipesRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when no recipes contain the product', async () => {
|
||||
mockRecipesRepo.findAllByProductId.mockResolvedValue([]);
|
||||
await service.recalculateForProduct('hh1', 'p1');
|
||||
expect(mockRecipesRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
309
packages/api/src/modules/recipes/recipes.service.ts
Normal file
309
packages/api/src/modules/recipes/recipes.service.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import type { ProductsRepository } from '../products/products.repository.js';
|
||||
import type { RecipesRepository } from './recipes.repository.js';
|
||||
import { NutritionCalculatorService } from './nutrition-calculator.service.js';
|
||||
import { toMetric } from './unit-conversion.service.js';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
import type {
|
||||
CreateRecipeInput,
|
||||
UpdateRecipeInput,
|
||||
RecipeQueryInput,
|
||||
ScaleRecipeInput,
|
||||
RecipeIngredient,
|
||||
NutritionInfo,
|
||||
} from '@meshitrack/shared';
|
||||
import type { ILlmProvider } from '../llm/llm-provider.interface.js';
|
||||
|
||||
interface Deps {
|
||||
recipesRepository: RecipesRepository;
|
||||
productsRepository: ProductsRepository;
|
||||
llmProvider: ILlmProvider;
|
||||
}
|
||||
|
||||
interface ConversionError {
|
||||
ingredientIndex: number;
|
||||
productId: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class RecipesService {
|
||||
private readonly recipesRepository: RecipesRepository;
|
||||
private readonly productsRepository: ProductsRepository;
|
||||
private readonly llmProvider: ILlmProvider;
|
||||
private readonly nutritionCalculator: NutritionCalculatorService;
|
||||
|
||||
public constructor({ recipesRepository, productsRepository, llmProvider }: Deps) {
|
||||
this.recipesRepository = recipesRepository;
|
||||
this.productsRepository = productsRepository;
|
||||
this.llmProvider = llmProvider;
|
||||
this.nutritionCalculator = new NutritionCalculatorService();
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: RecipeQueryInput) {
|
||||
return this.recipesRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
const recipe = await this.recipesRepository.findById(id, householdId);
|
||||
if (!recipe) throw new NotFoundError('Recipe not found');
|
||||
return recipe;
|
||||
}
|
||||
|
||||
public async create(data: CreateRecipeInput, householdId: string, createdBy: string) {
|
||||
const { normalizedIngredients, productMap } = await this.resolveAndNormalizeIngredients(
|
||||
data.ingredients,
|
||||
householdId,
|
||||
);
|
||||
|
||||
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||
this.nutritionCalculator.calculateRecipeNutrition(
|
||||
normalizedIngredients,
|
||||
productMap,
|
||||
data.servings,
|
||||
);
|
||||
|
||||
const warnings = this.nutritionCalculator.generateWarnings(perServingNutrition);
|
||||
|
||||
const storedIngredients = normalizedIngredients.map((ni, i) => ({
|
||||
...ni,
|
||||
nutritionContribution: ingredientContributions[i]!,
|
||||
}));
|
||||
|
||||
const { ingredients: _ingredients, ...rest } = data;
|
||||
return this.recipesRepository.create(
|
||||
rest,
|
||||
{
|
||||
ingredients: storedIngredients,
|
||||
totalNutrition,
|
||||
perServingNutrition,
|
||||
warnings,
|
||||
},
|
||||
householdId,
|
||||
createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateRecipeInput) {
|
||||
await this.getById(id, householdId);
|
||||
|
||||
let computed:
|
||||
| Partial<{
|
||||
ingredients: RecipeIngredient[];
|
||||
totalNutrition: NutritionInfo;
|
||||
perServingNutrition: NutritionInfo;
|
||||
warnings: string[];
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
if (data.ingredients || data.servings !== undefined) {
|
||||
// Re-fetch current recipe to get current servings if not updating them
|
||||
const current = await this.recipesRepository.findById(id, householdId);
|
||||
const servings = data.servings ?? current!.servings;
|
||||
const ingredientsInput =
|
||||
data.ingredients ??
|
||||
current!.ingredients.map((i) => ({
|
||||
productId: i.productId,
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit as string,
|
||||
...(i.preparation ? { preparation: i.preparation } : {}),
|
||||
isOptional: i.isOptional,
|
||||
}));
|
||||
|
||||
const { normalizedIngredients, productMap } = await this.resolveAndNormalizeIngredients(
|
||||
ingredientsInput,
|
||||
householdId,
|
||||
);
|
||||
|
||||
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||
this.nutritionCalculator.calculateRecipeNutrition(
|
||||
normalizedIngredients,
|
||||
productMap,
|
||||
servings,
|
||||
);
|
||||
|
||||
const warnings = this.nutritionCalculator.generateWarnings(perServingNutrition);
|
||||
|
||||
computed = {
|
||||
ingredients: normalizedIngredients.map((ni, i) => ({
|
||||
...ni,
|
||||
nutritionContribution: ingredientContributions[i]!,
|
||||
})),
|
||||
totalNutrition,
|
||||
perServingNutrition,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const { ingredients: _ingredients, ...rest } = data;
|
||||
const updated = await this.recipesRepository.update(id, householdId, rest, computed);
|
||||
if (!updated) throw new NotFoundError('Recipe not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
await this.getById(id, householdId);
|
||||
const deleted = await this.recipesRepository.softDelete(id, householdId);
|
||||
if (!deleted) throw new NotFoundError('Recipe not found');
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async scale(id: string, householdId: string, input: ScaleRecipeInput) {
|
||||
const recipe = await this.getById(id, householdId);
|
||||
const factor = input.targetServings / recipe.servings;
|
||||
|
||||
const scaledIngredients = recipe.ingredients.map((ing) => ({
|
||||
...ing,
|
||||
quantity: Math.round(ing.quantity * factor * 1000) / 1000,
|
||||
}));
|
||||
|
||||
const productMap = await this.buildProductMap(
|
||||
householdId,
|
||||
recipe.ingredients.map((i) => i.productId),
|
||||
);
|
||||
|
||||
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||
this.nutritionCalculator.calculateRecipeNutrition(
|
||||
scaledIngredients,
|
||||
productMap,
|
||||
input.targetServings,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
servings: input.targetServings,
|
||||
ingredients: scaledIngredients.map((ing, i) => ({
|
||||
...ing,
|
||||
nutritionContribution: ingredientContributions[i]!,
|
||||
})),
|
||||
totalNutrition,
|
||||
perServingNutrition,
|
||||
};
|
||||
}
|
||||
|
||||
public async importFromText(text: string, _householdId: string) {
|
||||
const parsed = await this.llmProvider.parseRecipe(text);
|
||||
if (!parsed) return { available: false as const };
|
||||
return { available: true as const, draft: parsed };
|
||||
}
|
||||
|
||||
public async importFromUrl(url: string, _householdId: string) {
|
||||
const parsed = await this.llmProvider.parseRecipeFromUrl(url);
|
||||
if (!parsed) return { available: false as const };
|
||||
return { available: true as const, draft: parsed };
|
||||
}
|
||||
|
||||
public async findByProduct(
|
||||
householdId: string,
|
||||
productId: string,
|
||||
query: { cursor?: string; limit?: number },
|
||||
) {
|
||||
return this.recipesRepository.findByProductId(householdId, productId, query);
|
||||
}
|
||||
|
||||
/** Called by products service after a product nutrition update. */
|
||||
public async recalculateForProduct(householdId: string, productId: string) {
|
||||
const recipes = await this.recipesRepository.findAllByProductId(householdId, productId);
|
||||
|
||||
for (const recipe of recipes) {
|
||||
const productIds = recipe.ingredients.map((i) => i.productId);
|
||||
const productMap = await this.buildProductMap(householdId, productIds);
|
||||
|
||||
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||
this.nutritionCalculator.calculateRecipeNutrition(
|
||||
recipe.ingredients,
|
||||
productMap,
|
||||
recipe.servings,
|
||||
);
|
||||
|
||||
const warnings = this.nutritionCalculator.generateWarnings(perServingNutrition);
|
||||
|
||||
const updatedIngredients = recipe.ingredients.map((ing, i) => ({
|
||||
...ing,
|
||||
nutritionContribution: ingredientContributions[i]!,
|
||||
}));
|
||||
|
||||
await this.recipesRepository.update(
|
||||
recipe._id.toString(),
|
||||
householdId,
|
||||
{},
|
||||
{
|
||||
ingredients: updatedIngredients as RecipeIngredient[],
|
||||
totalNutrition,
|
||||
perServingNutrition,
|
||||
warnings,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAndNormalizeIngredients(
|
||||
ingredients: Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
preparation?: string;
|
||||
isOptional?: boolean;
|
||||
}>,
|
||||
householdId: string,
|
||||
) {
|
||||
const productIds = [...new Set(ingredients.map((i) => i.productId))];
|
||||
const products = await this.productsRepository.findByIds(householdId, productIds);
|
||||
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
||||
|
||||
const conversionErrors: ConversionError[] = [];
|
||||
const normalizedIngredients: Array<Omit<RecipeIngredient, 'nutritionContribution'>> = [];
|
||||
|
||||
for (let i = 0; i < ingredients.length; i++) {
|
||||
const ing = ingredients[i]!;
|
||||
const product = productMap.get(ing.productId);
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundError(`Product "${ing.productId}" not found in household`);
|
||||
}
|
||||
|
||||
const result = toMetric(
|
||||
ing.quantity,
|
||||
ing.unit as never,
|
||||
product.servingUnit as 'g' | 'ml' | 'piece' | 'slice',
|
||||
product.densityGPerMl ?? undefined,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
conversionErrors.push({
|
||||
ingredientIndex: i,
|
||||
productId: ing.productId,
|
||||
code: result.code,
|
||||
message: result.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedIngredients.push({
|
||||
productId: ing.productId,
|
||||
productName: ing.productName,
|
||||
quantity: result.quantity,
|
||||
unit: result.unit,
|
||||
...(ing.unit !== result.unit
|
||||
? { originalQuantity: ing.quantity, originalUnit: ing.unit as never }
|
||||
: {}),
|
||||
...(ing.preparation ? { preparation: ing.preparation } : {}),
|
||||
isOptional: ing.isOptional ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
if (conversionErrors.length > 0) {
|
||||
throw new BadRequestError('Unit conversion failed for one or more ingredients', {
|
||||
conversionErrors: conversionErrors.map((e) => e.message),
|
||||
});
|
||||
}
|
||||
|
||||
return { normalizedIngredients, productMap };
|
||||
}
|
||||
|
||||
private async buildProductMap(householdId: string, productIds: string[]) {
|
||||
const products = await this.productsRepository.findByIds(householdId, productIds);
|
||||
return new Map(products.map((p) => [p._id.toString(), p]));
|
||||
}
|
||||
}
|
||||
132
packages/api/src/modules/recipes/unit-conversion.service.test.ts
Normal file
132
packages/api/src/modules/recipes/unit-conversion.service.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { toMetric } from './unit-conversion.service.js';
|
||||
|
||||
describe('toMetric', () => {
|
||||
describe('metric pass-through', () => {
|
||||
it('passes g through unchanged', () => {
|
||||
const r = toMetric(100, 'g', 'g');
|
||||
expect(r).toEqual({ ok: true, quantity: 100, unit: 'g' });
|
||||
});
|
||||
|
||||
it('passes ml through unchanged', () => {
|
||||
const r = toMetric(250, 'ml', 'ml');
|
||||
expect(r).toEqual({ ok: true, quantity: 250, unit: 'ml' });
|
||||
});
|
||||
|
||||
it('passes piece through unchanged', () => {
|
||||
const r = toMetric(2, 'piece', 'piece');
|
||||
expect(r).toEqual({ ok: true, quantity: 2, unit: 'piece' });
|
||||
});
|
||||
|
||||
it('passes slice through unchanged', () => {
|
||||
const r = toMetric(3, 'slice', 'slice');
|
||||
expect(r).toEqual({ ok: true, quantity: 3, unit: 'slice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mass conversions', () => {
|
||||
it('converts oz to g for a g-product', () => {
|
||||
const r = toMetric(1, 'oz', 'g');
|
||||
expect(r).toEqual({ ok: true, quantity: 28.35, unit: 'g' });
|
||||
});
|
||||
|
||||
it('converts lb to g for a g-product', () => {
|
||||
const r = toMetric(1, 'lb', 'g');
|
||||
expect(r).toEqual({ ok: true, quantity: 453.592, unit: 'g' });
|
||||
});
|
||||
|
||||
it('converts oz to ml using density for a ml-product', () => {
|
||||
// 1 oz = 28.3495 g; density 1.03 g/ml → 27.524... ml
|
||||
const r = toMetric(1, 'oz', 'ml', 1.03);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.unit).toBe('ml');
|
||||
expect(r.quantity).toBeCloseTo(27.524, 2);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns MISSING_DENSITY for oz → ml when density absent', () => {
|
||||
const r = toMetric(1, 'oz', 'ml');
|
||||
expect(r).toMatchObject({ ok: false, code: 'MISSING_DENSITY' });
|
||||
});
|
||||
|
||||
it('returns INCOMPATIBLE_UNITS for oz → piece', () => {
|
||||
const r = toMetric(1, 'oz', 'piece');
|
||||
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('volume conversions', () => {
|
||||
it('converts tsp to ml for a ml-product', () => {
|
||||
const r = toMetric(1, 'tsp', 'ml');
|
||||
expect(r).toEqual({ ok: true, quantity: 4.929, unit: 'ml' });
|
||||
});
|
||||
|
||||
it('converts tbsp to ml for a ml-product', () => {
|
||||
const r = toMetric(1, 'tbsp', 'ml');
|
||||
expect(r).toEqual({ ok: true, quantity: 14.787, unit: 'ml' });
|
||||
});
|
||||
|
||||
it('converts fl_oz to ml for a ml-product', () => {
|
||||
const r = toMetric(1, 'fl_oz', 'ml');
|
||||
expect(r).toEqual({ ok: true, quantity: 29.574, unit: 'ml' });
|
||||
});
|
||||
|
||||
it('converts cup to ml for a ml-product', () => {
|
||||
const r = toMetric(1, 'cup', 'ml');
|
||||
expect(r).toEqual({ ok: true, quantity: 236.588, unit: 'ml' });
|
||||
});
|
||||
|
||||
it('converts cup to g using density for a g-product', () => {
|
||||
// 1 cup = 236.588 ml; density 1.05 g/ml → 248.417 g
|
||||
const r = toMetric(1, 'cup', 'g', 1.05);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.unit).toBe('g');
|
||||
expect(r.quantity).toBeCloseTo(248.417, 2);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns MISSING_DENSITY for cup → g when density absent', () => {
|
||||
const r = toMetric(1, 'cup', 'g');
|
||||
expect(r).toMatchObject({ ok: false, code: 'MISSING_DENSITY' });
|
||||
});
|
||||
|
||||
it('returns INCOMPATIBLE_UNITS for cup → piece', () => {
|
||||
const r = toMetric(1, 'cup', 'piece');
|
||||
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mass to ml cross-conversion', () => {
|
||||
it('converts oz to ml using density', () => {
|
||||
// 1 oz = 28.3495 g; density 0.9 g/ml → 28.3495 / 0.9 = 31.499... ml
|
||||
const r = toMetric(1, 'oz', 'ml', 0.9);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.unit).toBe('ml');
|
||||
expect(r.quantity).toBeCloseTo(31.499, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns MISSING_DENSITY for oz → ml when density absent', () => {
|
||||
const r = toMetric(1, 'oz', 'ml');
|
||||
expect(r).toMatchObject({ ok: false, code: 'MISSING_DENSITY' });
|
||||
});
|
||||
|
||||
it('returns INCOMPATIBLE_UNITS for oz → piece', () => {
|
||||
const r = toMetric(1, 'oz', 'piece');
|
||||
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown unit', () => {
|
||||
it('returns INCOMPATIBLE_UNITS for unknown unit', () => {
|
||||
const r = toMetric(1, 'gallon' as never, 'g');
|
||||
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||
if (!r.ok) {
|
||||
expect(r.message).toContain('Unknown unit');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
119
packages/api/src/modules/recipes/unit-conversion.service.ts
Normal file
119
packages/api/src/modules/recipes/unit-conversion.service.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { RecipeUnit, ImperialUnit } from '@meshitrack/shared';
|
||||
|
||||
type AnyUnit = RecipeUnit | ImperialUnit;
|
||||
|
||||
interface ConversionSuccess {
|
||||
ok: true;
|
||||
quantity: number;
|
||||
unit: RecipeUnit;
|
||||
}
|
||||
|
||||
interface ConversionError {
|
||||
ok: false;
|
||||
code: 'MISSING_DENSITY' | 'INCOMPATIBLE_UNITS';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ConversionResult = ConversionSuccess | ConversionError;
|
||||
|
||||
// US customary volume → ml (exact per NIST)
|
||||
const VOLUME_TO_ML: Partial<Record<AnyUnit, number>> = {
|
||||
tsp: 4.92892,
|
||||
tbsp: 14.7868,
|
||||
fl_oz: 29.5735,
|
||||
cup: 236.588,
|
||||
};
|
||||
|
||||
// Mass → grams (exact)
|
||||
const MASS_TO_G: Partial<Record<AnyUnit, number>> = {
|
||||
oz: 28.3495,
|
||||
lb: 453.592,
|
||||
};
|
||||
|
||||
const METRIC_UNITS = new Set<AnyUnit>(['g', 'ml', 'piece', 'slice']);
|
||||
|
||||
/**
|
||||
* Converts a recipe ingredient quantity/unit to its metric RecipeUnit equivalent.
|
||||
*
|
||||
* - Already metric/discrete → returned unchanged.
|
||||
* - Mass imperial (oz, lb) → grams.
|
||||
* - Volume imperial (cup, tbsp, tsp, fl_oz) → ml.
|
||||
* - Mass↔volume cross-conversion requires densityGPerMl on the product;
|
||||
* returns MISSING_DENSITY error if absent.
|
||||
* - Discrete (piece, slice) cannot be converted from imperial;
|
||||
* returns INCOMPATIBLE_UNITS error.
|
||||
*/
|
||||
export function toMetric(
|
||||
quantity: number,
|
||||
unit: AnyUnit,
|
||||
productServingUnit: RecipeUnit,
|
||||
densityGPerMl?: number,
|
||||
): ConversionResult {
|
||||
// Already a valid storage unit — pass through.
|
||||
if (METRIC_UNITS.has(unit)) {
|
||||
return { ok: true, quantity, unit: unit as RecipeUnit };
|
||||
}
|
||||
|
||||
const massMultiplier = MASS_TO_G[unit];
|
||||
const volumeMultiplier = VOLUME_TO_ML[unit];
|
||||
|
||||
if (massMultiplier !== undefined) {
|
||||
const inGrams = quantity * massMultiplier;
|
||||
|
||||
if (productServingUnit === 'g') {
|
||||
return { ok: true, quantity: round(inGrams), unit: 'g' };
|
||||
}
|
||||
|
||||
if (productServingUnit === 'ml') {
|
||||
if (!densityGPerMl) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MISSING_DENSITY',
|
||||
message: `Cannot convert mass unit "${unit}" to ml without product density (densityGPerMl).`,
|
||||
};
|
||||
}
|
||||
return { ok: true, quantity: round(inGrams / densityGPerMl), unit: 'ml' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INCOMPATIBLE_UNITS',
|
||||
message: `Cannot convert mass unit "${unit}" to discrete unit "${productServingUnit}".`,
|
||||
};
|
||||
}
|
||||
|
||||
if (volumeMultiplier !== undefined) {
|
||||
const inMl = quantity * volumeMultiplier;
|
||||
|
||||
if (productServingUnit === 'ml') {
|
||||
return { ok: true, quantity: round(inMl), unit: 'ml' };
|
||||
}
|
||||
|
||||
if (productServingUnit === 'g') {
|
||||
if (!densityGPerMl) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MISSING_DENSITY',
|
||||
message: `Cannot convert volume unit "${unit}" to g without product density (densityGPerMl).`,
|
||||
};
|
||||
}
|
||||
return { ok: true, quantity: round(inMl * densityGPerMl), unit: 'g' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INCOMPATIBLE_UNITS',
|
||||
message: `Cannot convert volume unit "${unit}" to discrete unit "${productServingUnit}".`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INCOMPATIBLE_UNITS',
|
||||
message: `Unknown unit "${unit}".`,
|
||||
};
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue