Full tests coverage
This commit is contained in:
parent
99134d8556
commit
02d782c3da
157 changed files with 1074 additions and 34670 deletions
|
|
@ -1,162 +0,0 @@
|
|||
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('../../../src/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 '../../../src/modules/freshness-rules/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
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('../../../src/modules/freshness-rules/freshness-rules.repository.js', () => ({
|
||||
FreshnessRulesRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findApplicableRule = vi.fn();
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import freshnessRulesRoutes from '../../../src/modules/freshness-rules/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FreshnessRulesService } from '../../../src/modules/freshness-rules/freshness-rules.service.js';
|
||||
import { NotFoundError, BadRequestError } from '../../../src/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MealPlanRepository } from '../../../src/modules/meal-plans/meal-plans.repository.js';
|
||||
import { MealPlanStatus } from '@meshitrack/shared';
|
||||
|
||||
const { mockSave, MockMealPlanModel } = vi.hoisted(() => {
|
||||
const mockSave = vi.fn();
|
||||
function MockModel(this: { save: typeof mockSave }, data: unknown) {
|
||||
Object.assign(this, data);
|
||||
this.save = mockSave;
|
||||
}
|
||||
Object.assign(MockModel, {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOneAndUpdate: vi.fn(),
|
||||
findOneAndDelete: vi.fn(),
|
||||
});
|
||||
return { mockSave, MockMealPlanModel: MockModel };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/schemas/meal-plan.schema.js', () => ({
|
||||
MealPlanModel: MockMealPlanModel,
|
||||
}));
|
||||
|
||||
const { MealPlanModel } = await import('../../../src/schemas/meal-plan.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(MealPlanRepository.name, () => {
|
||||
let repo: MealPlanRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new MealPlanRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('applies householdId filter', async () => {
|
||||
const chain = makeChain([]);
|
||||
vi.mocked(MealPlanModel.find).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(MealPlanModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ householdId: 'hh1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByWeek', () => {
|
||||
it('queries by householdId and weekStartDate', async () => {
|
||||
const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' };
|
||||
vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never);
|
||||
|
||||
const result = await repo.findByWeek('hh1', '2026-05-18');
|
||||
expect(MealPlanModel.findOne).toHaveBeenCalledWith({
|
||||
householdId: 'hh1',
|
||||
weekStartDate: '2026-05-18',
|
||||
});
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns new document', async () => {
|
||||
const plainDoc = { _id: 'new-id', weekStartDate: '2026-05-18' };
|
||||
mockSave.mockResolvedValue({ toObject: () => plainDoc });
|
||||
|
||||
const result = await repo.create({ weekStartDate: '2026-05-18' });
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(plainDoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('updates status only', async () => {
|
||||
vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||
|
||||
await repo.updateStatus('mp1', 'hh1', MealPlanStatus.ACTIVE);
|
||||
expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'mp1', householdId: 'hh1' },
|
||||
{ $set: { status: MealPlanStatus.ACTIVE } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('finds meal plan by id and householdId', async () => {
|
||||
const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' };
|
||||
vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never);
|
||||
|
||||
const result = await repo.findById('mp1', 'hh1');
|
||||
expect(MealPlanModel.findOne).toHaveBeenCalledWith({
|
||||
_id: 'mp1',
|
||||
householdId: 'hh1',
|
||||
});
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates meal plan using findOneAndUpdate', async () => {
|
||||
vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||
|
||||
await repo.update('mp1', 'hh1', { status: MealPlanStatus.ACTIVE });
|
||||
expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'mp1', householdId: 'hh1' },
|
||||
{ $set: { status: MealPlanStatus.ACTIVE } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes meal plan using findOneAndDelete', async () => {
|
||||
vi.mocked(MealPlanModel.findOneAndDelete).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||
|
||||
const result = await repo.delete('mp1', 'hh1');
|
||||
expect(MealPlanModel.findOneAndDelete).toHaveBeenCalledWith({
|
||||
_id: 'mp1',
|
||||
householdId: 'hh1',
|
||||
});
|
||||
expect(result).toEqual({ _id: 'mp1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHousehold pagination cursor', () => {
|
||||
it('applies pagination filter when cursor is provided', async () => {
|
||||
const chain = makeChain([]);
|
||||
vi.mocked(MealPlanModel.find).mockReturnValue(chain as never);
|
||||
|
||||
const cursor = Buffer.from('some-mongo-id').toString('base64');
|
||||
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||
|
||||
expect(MealPlanModel.find).toHaveBeenCalledWith({
|
||||
householdId: 'hh1',
|
||||
_id: { $gt: 'some-mongo-id' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
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 { MealPlanStatus } 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,
|
||||
mockFindByWeek,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockUpdateStatus,
|
||||
mockDelete,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockFindByWeek: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockUpdateStatus: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/meal-plans/meal-plans.repository.js', () => ({
|
||||
MealPlanRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findByWeek = mockFindByWeek;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
updateStatus = mockUpdateStatus;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock prerequisite repositories to allow SuggestionEngine/Gap to resolve
|
||||
vi.mock('../../../src/modules/recipes/recipes.repository.js', () => ({
|
||||
RecipesRepository: class {
|
||||
findByHousehold = vi.fn().mockResolvedValue({ data: [] });
|
||||
findById = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/pantry/pantry.repository.js', () => ({
|
||||
PantryRepository: class {
|
||||
findActiveByHousehold = vi.fn().mockResolvedValue([]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/nutrition-targets/nutrition-target.repository.js', () => ({
|
||||
NutritionTargetRepository: class {
|
||||
findByUser = vi.fn().mockResolvedValue(null);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findByIds = vi.fn().mockResolvedValue([]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import mealPlanRoutes from '../../../src/modules/meal-plans/meal-plans.routes.js';
|
||||
|
||||
const emptyNutrition = { calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, sugar: 0, sodium: 0, saturatedFat: 0, cholesterol: 0 };
|
||||
|
||||
function makePlan(overrides = {}) {
|
||||
return {
|
||||
_id: 'plan-1',
|
||||
householdId: 'hh1',
|
||||
weekStartDate: '2026-05-10',
|
||||
days: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: emptyNutrition,
|
||||
})),
|
||||
status: MealPlanStatus.DRAFT,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('meal-plan.routes', () => {
|
||||
let app: any;
|
||||
|
||||
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(mealPlanRoutes);
|
||||
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/meal-plans', () => {
|
||||
it('returns paginated results', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [makePlan()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0]._id).toBe('plan-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/week/:weekStartDate', () => {
|
||||
it('returns matched weekly plan', async () => {
|
||||
mockFindByWeek.mockResolvedValue(makePlan());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/week/2026-05-10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('plan-1');
|
||||
});
|
||||
|
||||
it('returns not-found message structure if missing', async () => {
|
||||
mockFindByWeek.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/week/2026-05-10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().message).toBe('No meal plan scheduled for this week');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/meal-plans', () => {
|
||||
it('creates a new plan', async () => {
|
||||
mockFindByWeek.mockResolvedValue(null);
|
||||
mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-plan-id', createdAt: new Date(), updatedAt: new Date() }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/meal-plans',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
weekStartDate: '2026-05-10',
|
||||
days: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: emptyNutrition,
|
||||
})),
|
||||
status: 'draft',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('new-plan-id');
|
||||
expect(body.status).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/suggestions', () => {
|
||||
it('returns list of scored recipe recommendations', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/suggestions?limit=2',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/:id/gap', () => {
|
||||
it('returns missing elements report', async () => {
|
||||
mockFindById.mockResolvedValue(makePlan());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1/gap',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.mealPlanId).toBe('plan-1');
|
||||
expect(Array.isArray(body.missingItems)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||
it('returns plan if found', async () => {
|
||||
const planWithMeal = makePlan({
|
||||
createdAt: new Date(),
|
||||
days: [{
|
||||
date: '2026-05-10',
|
||||
meals: [{
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
type: 'dinner',
|
||||
recipeId: 'recipe-1',
|
||||
recipeName: 'Spaghetti',
|
||||
servings: 2,
|
||||
perServingNutrition: emptyNutrition,
|
||||
customName: 'My Pasta',
|
||||
customNutrition: emptyNutrition,
|
||||
notes: 'Very yummy',
|
||||
}],
|
||||
dailyNutritionTotal: emptyNutrition,
|
||||
}]
|
||||
});
|
||||
mockFindById.mockResolvedValue(planWithMeal);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('plan-1');
|
||||
expect(res.json().days[0].meals).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||
it('updates plan content and returns it', async () => {
|
||||
mockUpdate.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/meal-plans/:id/status', () => {
|
||||
it('updates plan status directly and returns it', async () => {
|
||||
mockUpdateStatus.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1/status',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/meal-plans/:id', () => {
|
||||
it('deletes the plan and returns 204', async () => {
|
||||
mockDelete.mockResolvedValue(makePlan());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MealPlanService } from '../../../src/modules/meal-plans/meal-plans.service.js';
|
||||
import type { MealPlanRepository } from '../../../src/modules/meal-plans/meal-plans.repository.js';
|
||||
import { MealPlanStatus, MealType } from '@meshitrack/shared';
|
||||
import { BadRequestError, NotFoundError } from '../../../src/common/errors.js';
|
||||
|
||||
describe(MealPlanService.name, () => {
|
||||
let service: MealPlanService;
|
||||
let mockRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByWeek: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as never;
|
||||
|
||||
service = new MealPlanService({
|
||||
mealPlanRepository: mockRepo as unknown as MealPlanRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const query = { limit: 10 };
|
||||
const mockResult = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRepo.findByHousehold.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.list('hh1', query);
|
||||
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', query);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns plan if found', async () => {
|
||||
const mockPlan = { _id: 'p1' };
|
||||
mockRepo.findById.mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await service.getById('p1', 'hh1');
|
||||
expect(mockRepo.findById).toHaveBeenCalledWith('p1', 'hh1');
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('p1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const mockPerServingNutrition = {
|
||||
calories: 100,
|
||||
protein: 10,
|
||||
carbs: 20,
|
||||
fat: 5,
|
||||
fiber: 2,
|
||||
sugar: 3,
|
||||
sodium: 100,
|
||||
saturatedFat: 1,
|
||||
cholesterol: 10,
|
||||
};
|
||||
|
||||
const emptyDays = Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
sodium: 0,
|
||||
saturatedFat: 0,
|
||||
cholesterol: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
it('calculates day totals and delegates to repository', async () => {
|
||||
mockRepo.findByWeek.mockResolvedValue(null);
|
||||
mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' }));
|
||||
|
||||
const daysWithMeal = [...emptyDays];
|
||||
daysWithMeal[0] = {
|
||||
date: '2026-05-10',
|
||||
meals: [
|
||||
{
|
||||
id: 'meal-uuid-1',
|
||||
type: MealType.BREAKFAST,
|
||||
recipeName: 'Eggs',
|
||||
servings: 2,
|
||||
perServingNutrition: mockPerServingNutrition,
|
||||
},
|
||||
],
|
||||
// Let's deliberately pass incorrect values to verify the service forces recalculation!
|
||||
dailyNutritionTotal: { calories: 999, protein: 99, carbs: 99, fat: 99 },
|
||||
};
|
||||
|
||||
const input = {
|
||||
weekStartDate: '2026-05-10',
|
||||
days: daysWithMeal,
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
const result = await service.create('hh1', 'user1', input);
|
||||
|
||||
expect(mockRepo.findByWeek).toHaveBeenCalledWith('hh1', '2026-05-10');
|
||||
expect(mockRepo.create).toHaveBeenCalled();
|
||||
|
||||
// Verify recalculation happened (perServing x 2 servings)
|
||||
expect(result.days[0].dailyNutritionTotal).toEqual({
|
||||
calories: 200,
|
||||
protein: 20,
|
||||
carbs: 40,
|
||||
fat: 10,
|
||||
fiber: 4,
|
||||
sugar: 6,
|
||||
sodium: 200,
|
||||
saturatedFat: 2,
|
||||
cholesterol: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses customNutrition over perServingNutrition if present', async () => {
|
||||
mockRepo.findByWeek.mockResolvedValue(null);
|
||||
mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' }));
|
||||
|
||||
const daysWithCustom = [...emptyDays];
|
||||
daysWithCustom[1] = {
|
||||
date: '2026-05-11',
|
||||
meals: [
|
||||
{
|
||||
id: 'meal-uuid-2',
|
||||
type: MealType.LUNCH,
|
||||
recipeName: 'Custom Item',
|
||||
servings: 1,
|
||||
perServingNutrition: mockPerServingNutrition, // 100 calories
|
||||
customNutrition: {
|
||||
calories: 300,
|
||||
protein: 30,
|
||||
carbs: 5,
|
||||
fat: 15,
|
||||
},
|
||||
},
|
||||
],
|
||||
dailyNutritionTotal: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
};
|
||||
|
||||
const input = {
|
||||
weekStartDate: '2026-05-10',
|
||||
days: daysWithCustom,
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
const result = await service.create('hh1', 'user1', input);
|
||||
expect(result.days[1].dailyNutritionTotal.calories).toBe(300);
|
||||
expect(result.days[1].dailyNutritionTotal.protein).toBe(30);
|
||||
});
|
||||
|
||||
it('throws BadRequestError if plan already exists for the week', async () => {
|
||||
mockRepo.findByWeek.mockResolvedValue({ _id: 'existing-id' });
|
||||
const input = {
|
||||
weekStartDate: '2026-05-10',
|
||||
days: emptyDays,
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
await expect(service.create('hh1', 'user1', input)).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const existingPlan = { _id: 'p1', householdId: 'hh1', status: MealPlanStatus.DRAFT };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo.findById.mockResolvedValue(existingPlan);
|
||||
});
|
||||
|
||||
it('updates values and recalculates days if updated', async () => {
|
||||
mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data }));
|
||||
|
||||
const emptyDays = Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
sodium: 0,
|
||||
saturatedFat: 0,
|
||||
cholesterol: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await service.update('p1', 'hh1', {
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
days: emptyDays,
|
||||
});
|
||||
|
||||
expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', {
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
days: emptyDays,
|
||||
});
|
||||
expect(result.status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
|
||||
it('supports updating shoppingListId', async () => {
|
||||
mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data }));
|
||||
const result = await service.update('p1', 'hh1', { shoppingListId: 'sl-1' });
|
||||
expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { shoppingListId: 'sl-1' });
|
||||
expect((result as any).shoppingListId).toBe('sl-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if update returns null', async () => {
|
||||
mockRepo.update.mockResolvedValue(null);
|
||||
await expect(service.update('p1', 'hh1', { status: MealPlanStatus.ACTIVE })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('delegates update status to repository', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.updateStatus.mockResolvedValue({ _id: 'p1', status: MealPlanStatus.ARCHIVED });
|
||||
|
||||
const result = await service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED);
|
||||
expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED);
|
||||
expect(result.status).toBe(MealPlanStatus.ARCHIVED);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if updateStatus returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.updateStatus.mockResolvedValue(null);
|
||||
await expect(service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('delegates deletion if found', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.delete.mockResolvedValue({ _id: 'p1' });
|
||||
|
||||
const result = await service.delete('p1', 'hh1');
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1');
|
||||
expect(result).toEqual({ _id: 'p1' });
|
||||
});
|
||||
|
||||
it('throws NotFoundError if delete returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.delete.mockResolvedValue(null);
|
||||
await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ShoppingGapService } from '../../../src/modules/meal-plans/shopping-gap.service.js';
|
||||
import type { MealPlanRepository } from '../../../src/modules/meal-plans/meal-plans.repository.js';
|
||||
import type { RecipesRepository } from '../../../src/modules/recipes/recipes.repository.js';
|
||||
import type { PantryRepository } from '../../../src/modules/pantry/pantry.repository.js';
|
||||
import type { ProductsRepository } from '../../../src/modules/products/products.repository.js';
|
||||
import { NotFoundError } from '../../../src/common/errors.js';
|
||||
|
||||
describe(ShoppingGapService.name, () => {
|
||||
let service: ShoppingGapService;
|
||||
let mockMealRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockProductsRepo: { [K in keyof ProductsRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockMealRepo = { findById: vi.fn() } as never;
|
||||
mockRecipesRepo = { findById: vi.fn() } as never;
|
||||
mockPantryRepo = { findActiveByHousehold: vi.fn() } as never;
|
||||
mockProductsRepo = { findByIds: vi.fn() } as never;
|
||||
|
||||
service = new ShoppingGapService({
|
||||
mealPlanRepository: mockMealRepo as unknown as MealPlanRepository,
|
||||
recipesRepository: mockRecipesRepo as unknown as RecipesRepository,
|
||||
pantryRepository: mockPantryRepo as unknown as PantryRepository,
|
||||
productsRepository: mockProductsRepo as unknown as ProductsRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateGap', () => {
|
||||
it('throws NotFoundError if plan is missing', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.calculateGap('hh1', 'p1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('correctly scales recipe ingredients and contrasts against pantry', async () => {
|
||||
// 1. Setup Meal Plan with 1 meal
|
||||
// Recipe A planned for 4 servings.
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan1',
|
||||
days: [
|
||||
{
|
||||
meals: [
|
||||
{ recipeId: 'recipe1', servings: 4 }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 2. Recipe 1: serves 2, needs 100g of ProdA (total needed = 200g for 4 servings)
|
||||
mockRecipesRepo.findById.mockResolvedValue({
|
||||
_id: 'recipe1',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{ productId: 'prodA', quantity: 100, unit: 'g', isOptional: false }
|
||||
]
|
||||
});
|
||||
|
||||
// 3. Products Info
|
||||
mockProductsRepo.findByIds.mockResolvedValue([
|
||||
{ _id: 'prodA', name: 'Flour', category: 'baking' }
|
||||
]);
|
||||
|
||||
// 4. Pantry only has 50g of ProdA. Missing amount should be 150g!
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prodA', quantity: 50 }
|
||||
]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan1');
|
||||
|
||||
expect(result.mealPlanId).toBe('plan1');
|
||||
expect(result.missingItems.length).toBe(1);
|
||||
|
||||
const gap = result.missingItems[0]!;
|
||||
expect(gap.productId).toBe('prodA');
|
||||
expect(gap.productName).toBe('Flour');
|
||||
expect(gap.requiredQuantity).toBe(200); // 100g * (4 planned / 2 base)
|
||||
expect(gap.pantryQuantity).toBe(50);
|
||||
expect(gap.missingQuantity).toBe(150);
|
||||
expect(gap.unit).toBe('g');
|
||||
});
|
||||
|
||||
it('does not include products that are fully stocked', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan2',
|
||||
days: [
|
||||
{
|
||||
meals: [{ recipeId: 'recipe1', servings: 2 }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockResolvedValue({
|
||||
_id: 'recipe1',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{ productId: 'prodB', quantity: 50, unit: 'g', isOptional: false }
|
||||
]
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodB', name: 'Salt' }]);
|
||||
|
||||
// Pantry has 100g (more than enough)
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([{ productId: 'prodB', quantity: 100 }]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan2');
|
||||
expect(result.missingItems.length).toBe(0);
|
||||
});
|
||||
|
||||
it('aggregates duplicate ingredients and sorts by product name', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-multi',
|
||||
days: [
|
||||
{
|
||||
meals: [
|
||||
{ recipeId: 'recipeA', servings: 1 },
|
||||
{ recipeId: 'recipeB', servings: 1 },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockImplementation(async (id) => {
|
||||
if (id === 'recipeA') {
|
||||
return {
|
||||
_id: 'recipeA', servings: 1,
|
||||
ingredients: [{ productId: 'prod1', quantity: 10, isOptional: false }]
|
||||
};
|
||||
}
|
||||
return {
|
||||
_id: 'recipeB', servings: 1,
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 20, isOptional: false },
|
||||
{ productId: 'prod2', quantity: 5, isOptional: false },
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([
|
||||
{ _id: 'prod1', name: 'Banana' },
|
||||
{ _id: 'prod2', name: 'Apple' },
|
||||
]);
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan-multi');
|
||||
|
||||
expect(result.missingItems).toHaveLength(2);
|
||||
expect(result.missingItems[0].productName).toBe('Apple');
|
||||
expect(result.missingItems[1].productName).toBe('Banana');
|
||||
expect(result.missingItems[1].requiredQuantity).toBe(30);
|
||||
});
|
||||
|
||||
it('covers fallback paths for missing list, recipe properties and pantry quantities', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-empty',
|
||||
});
|
||||
mockProductsRepo.findByIds.mockResolvedValue([]);
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
let res = await service.calculateGap('hh1', 'plan-empty');
|
||||
expect(res.missingItems).toHaveLength(0);
|
||||
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-missing',
|
||||
days: [
|
||||
{
|
||||
meals: [{ recipeId: 'recipeC', servings: 1 }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockResolvedValue({
|
||||
_id: 'recipeC',
|
||||
servings: 1,
|
||||
ingredients: [
|
||||
{ productId: 'prod3', quantity: 10, isOptional: false }
|
||||
]
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([
|
||||
{ _id: 'prod3' }
|
||||
]);
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod3' }
|
||||
]);
|
||||
|
||||
res = await service.calculateGap('hh1', 'plan-missing');
|
||||
expect(res.missingItems).toHaveLength(1);
|
||||
const itm = res.missingItems[0]!;
|
||||
expect(itm.unit).toBe('g');
|
||||
expect(itm.productName).toBe('Unknown Ingredient');
|
||||
expect(itm.category).toBe('other');
|
||||
expect(itm.pantryQuantity).toBe(0);
|
||||
});
|
||||
|
||||
it('skips optional ingredients, handles missing recipes and defaults servings to 1', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan-edge',
|
||||
days: [
|
||||
{
|
||||
meals: [
|
||||
{ recipeId: 'recipeExist', servings: 2 },
|
||||
{ recipeId: 'recipeNotExist', servings: 1 },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockImplementation(async (id) => {
|
||||
if (id === 'recipeExist') {
|
||||
return {
|
||||
_id: 'recipeExist',
|
||||
servings: 0,
|
||||
ingredients: [
|
||||
{ productId: 'prodIng', quantity: 5, isOptional: false },
|
||||
{ productId: 'prodOptional', quantity: 10, isOptional: true },
|
||||
]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodIng', name: 'Ingredient' }]);
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan-edge');
|
||||
expect(result.missingItems).toHaveLength(1);
|
||||
expect(result.missingItems[0].productId).toBe('prodIng');
|
||||
expect(result.missingItems[0].requiredQuantity).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SuggestionEngineService } from '../../../src/modules/meal-plans/suggestion-engine.service.js';
|
||||
import type { RecipesRepository } from '../../../src/modules/recipes/recipes.repository.js';
|
||||
import type { PantryRepository } from '../../../src/modules/pantry/pantry.repository.js';
|
||||
import type { MealPlanRepository } from '../../../src/modules/meal-plans/meal-plans.repository.js';
|
||||
import type { NutritionTargetRepository } from '../../../src/modules/nutrition-targets/nutrition-target.repository.js';
|
||||
|
||||
describe(SuggestionEngineService.name, () => {
|
||||
let service: SuggestionEngineService;
|
||||
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockMealPlanRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockNutritionRepo: { [K in keyof NutritionTargetRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-20T00:00:00Z'));
|
||||
|
||||
mockRecipesRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
} as never;
|
||||
|
||||
mockPantryRepo = {
|
||||
findActiveByHousehold: vi.fn(),
|
||||
} as never;
|
||||
|
||||
mockMealPlanRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
} as never;
|
||||
|
||||
mockNutritionRepo = {
|
||||
findByUser: vi.fn(),
|
||||
} as never;
|
||||
|
||||
service = new SuggestionEngineService({
|
||||
recipesRepository: mockRecipesRepo as unknown as RecipesRepository,
|
||||
pantryRepository: mockPantryRepo as unknown as PantryRepository,
|
||||
mealPlanRepository: mockMealPlanRepo as unknown as MealPlanRepository,
|
||||
nutritionTargetRepository: mockNutritionRepo as unknown as NutritionTargetRepository,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getSuggestions', () => {
|
||||
it('correctly ranks recipes based on inventory coverage and freshness', async () => {
|
||||
// 1. Set up recipes:
|
||||
// - Recipe A: Needs Product 1 (2 units) and Product 2 (1 unit)
|
||||
// - Recipe B: Needs Product 3 (1 unit)
|
||||
const recipeA = {
|
||||
_id: 'recipeA',
|
||||
name: 'Recipe A',
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 2, isOptional: false },
|
||||
{ productId: 'prod2', quantity: 1, isOptional: false },
|
||||
],
|
||||
perServingNutrition: { calories: 400, protein: 30, carbs: 40, fat: 10 }, // balanced
|
||||
};
|
||||
|
||||
const recipeB = {
|
||||
_id: 'recipeB',
|
||||
name: 'Recipe B',
|
||||
ingredients: [
|
||||
{ productId: 'prod3', quantity: 1, isOptional: false },
|
||||
],
|
||||
perServingNutrition: { calories: 600, protein: 10, carbs: 100, fat: 15 }, // high carb
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({
|
||||
data: [recipeA, recipeB],
|
||||
pagination: { hasMore: false },
|
||||
});
|
||||
|
||||
// 2. Set up Pantry inventory:
|
||||
// We have Product 1 in abundance (expiringSoon).
|
||||
// We have Product 2 (fresh).
|
||||
// Product 3 is NOT in pantry.
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{
|
||||
productId: 'prod1',
|
||||
quantity: 10,
|
||||
freshnessEstimate: { daysRemaining: 2, urgency: 'expiringSoon' },
|
||||
},
|
||||
{
|
||||
productId: 'prod2',
|
||||
quantity: 5,
|
||||
freshnessEstimate: { daysRemaining: 15, urgency: 'fresh' },
|
||||
},
|
||||
]);
|
||||
|
||||
// 3. Set up standard nutrition target (Maintenance: 30p/40c/30f split)
|
||||
// Macro split match logic:
|
||||
// Recipe A: 400cals, 30g Protein(120cals=30%), 40g Carbs(160cals=40%), 10g Fat(90cals=22.5%) -> highly aligned!
|
||||
mockNutritionRepo.findByUser.mockResolvedValue({
|
||||
dailyCalories: 2000,
|
||||
proteinG: 150, // (150 * 4) = 600cals (30%)
|
||||
carbsG: 200, // (200 * 4) = 800cals (40%)
|
||||
fatG: 67, // (67 * 9) = 603cals (30%)
|
||||
});
|
||||
|
||||
// 4. Set up recent meal plans (empty history -> 100% Variety for all)
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
// Run suggestion fetch
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
|
||||
// Assertions:
|
||||
expect(suggestions.length).toBe(2);
|
||||
|
||||
// Recipe A should clearly rank #1 (100% Coverage, using urgent items, highly nutritious match)
|
||||
const top = suggestions[0]!;
|
||||
expect(top.recipeId).toBe('recipeA');
|
||||
expect(top.scores.coverage).toBe(1); // full coverage
|
||||
// Urgency: (expiringSoon[0.7] + fresh[0.1]) / 2 = 0.4
|
||||
expect(top.scores.urgency).toBeGreaterThan(0.3);
|
||||
expect(top.scores.variety).toBe(1); // never eaten
|
||||
|
||||
// Recipe B should have 0 coverage and thus lower totalScore
|
||||
const bottom = suggestions[1]!;
|
||||
expect(bottom.recipeId).toBe('recipeB');
|
||||
expect(bottom.scores.coverage).toBe(0);
|
||||
expect(bottom.totalScore).toBeLessThan(top.totalScore);
|
||||
});
|
||||
|
||||
it('penalizes recipes eaten recently (Variety score)', async () => {
|
||||
const recipeX = {
|
||||
_id: 'recipeX',
|
||||
name: 'Recipe X',
|
||||
ingredients: [],
|
||||
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 2 },
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeX] });
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
|
||||
// Fake history: Recipe X was eaten 7 days ago
|
||||
const date7DaysAgo = new Date();
|
||||
date7DaysAgo.setDate(date7DaysAgo.getDate() - 7);
|
||||
const dateStr = date7DaysAgo.toISOString().split('T')[0];
|
||||
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
days: [
|
||||
{
|
||||
date: dateStr,
|
||||
meals: [{ recipeId: 'recipeX' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
|
||||
// Variety calculation: 7 days ago / 14 days = 0.5
|
||||
expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
it('triggers reasoning branches for partial coverage and urgent items', async () => {
|
||||
const recipeC = {
|
||||
_id: 'recipeC',
|
||||
name: 'Recipe C',
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 10, isOptional: false },
|
||||
{ productId: 'prod2', quantity: 10, isOptional: false },
|
||||
],
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeC] });
|
||||
|
||||
// 1. Coverage: (10/10 + 5/10)/2 = 0.75 (hits >0.5)
|
||||
// 2. Urgency: both set to urgent = 1.0 (hits >0.7)
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod1', quantity: 10, freshnessEstimate: { urgency: 'urgent' } },
|
||||
{ productId: 'prod2', quantity: 5, freshnessEstimate: { urgency: 'urgent' } },
|
||||
]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
expect(suggestions[0]!.scores.coverage).toBe(0.75);
|
||||
expect(suggestions[0]!.scores.urgency).toBe(1);
|
||||
expect(suggestions[0]!.reasoning).toContain('Uses several ingredients already stocked in your pantry.');
|
||||
expect(suggestions[0]!.reasoning).toContain('High priority: Saves expiring pantry items from going to waste!');
|
||||
});
|
||||
|
||||
it('triggers reasoning for moderately soon-to-expire items', async () => {
|
||||
const recipeD = {
|
||||
_id: 'recipeD',
|
||||
name: 'Recipe D',
|
||||
ingredients: [{ productId: 'prod1', quantity: 5, isOptional: false }],
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeD] });
|
||||
|
||||
// Urgency soon/expiringSoon has weight 0.7 (hits >0.4 branch)
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod1', quantity: 5, freshnessEstimate: { urgency: 'expiringSoon' } },
|
||||
]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
expect(suggestions[0]!.scores.urgency).toBe(0.7);
|
||||
expect(suggestions[0]!.reasoning).toContain('Helps use up items that should be consumed soon.');
|
||||
});
|
||||
|
||||
it('aggregates duplicate pantry items and handles normal/default urgencies', async () => {
|
||||
const recipeE = {
|
||||
_id: 'recipeE',
|
||||
name: 'Recipe E',
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 5, isOptional: false },
|
||||
],
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeE] });
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prod1', quantity: 2, freshnessEstimate: { daysRemaining: 5, urgency: 'normal' } },
|
||||
{ productId: 'prod1', quantity: 3, freshnessEstimate: { daysRemaining: 10, urgency: 'unknown-type' } },
|
||||
]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
expect(suggestions[0]!.scores.coverage).toBe(1);
|
||||
expect(suggestions[0]!.scores.urgency).toBe(0.3);
|
||||
});
|
||||
|
||||
it('covers boundary logic for nameless recipes, custom meals, default targets and private weights', async () => {
|
||||
// 1. Nameless recipe and recipe without ingredients
|
||||
const rawRecipe = { _id: 'recipeMissingProps' };
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [rawRecipe] });
|
||||
|
||||
// 2. Active target with partial/falsy info
|
||||
mockNutritionRepo.findByUser.mockResolvedValue({ dailyCalories: 0, proteinG: 0 });
|
||||
|
||||
// 3. Last eaten containing a custom meal without recipeId (should continue)
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
days: [
|
||||
{
|
||||
date: '2026-05-19',
|
||||
meals: [
|
||||
{ customName: 'Snack' }, // no recipeId!
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
|
||||
// 4. Direct call to getUrgencyWeight default branch
|
||||
const defaultWeight = (service as any).getUrgencyWeight('mystery-status');
|
||||
expect(defaultWeight).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { FreshnessCalculatorService } from '../../../src/modules/pantry/freshness-calculator.service.js';
|
||||
import { ItemStatus, FreshnessUrgency, FreshnessSource, StorageLocation } from '@meshitrack/shared';
|
||||
|
||||
describe(FreshnessCalculatorService.name, () => {
|
||||
const service = new FreshnessCalculatorService();
|
||||
|
||||
const baseItem = {
|
||||
status: ItemStatus.SEALED,
|
||||
storageLocation: StorageLocation.FRIDGE,
|
||||
purchaseDate: new Date('2024-01-01'),
|
||||
expirationDate: undefined,
|
||||
openedDate: undefined,
|
||||
preparedDate: undefined,
|
||||
};
|
||||
|
||||
const rule = {
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
freezerLifeDays: 90,
|
||||
};
|
||||
|
||||
describe('calculate', () => {
|
||||
it('uses packaging expiration date when present', () => {
|
||||
const item = { ...baseItem, expirationDate: new Date('2099-12-31') };
|
||||
const result = service.calculate(item, rule);
|
||||
expect(result.source).toBe(FreshnessSource.PACKAGING);
|
||||
expect(result.estimatedExpiryDate).toEqual(new Date('2099-12-31'));
|
||||
});
|
||||
|
||||
it('falls back to 7-day default when no rule provided', () => {
|
||||
const result = service.calculate(baseItem, null);
|
||||
expect(result.source).toBe(FreshnessSource.RULE);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 7);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses freezerLifeDays for freezer storage', () => {
|
||||
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
|
||||
const result = service.calculate(item, rule);
|
||||
expect(result.source).toBe(FreshnessSource.RULE);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 90);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses shelfLifeDays for sealed items in freezer without freezerLifeDays', () => {
|
||||
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
|
||||
const ruleNoFreezer = { shelfLifeDays: 14, openedLifeDays: 7 };
|
||||
const result = service.calculate(item, ruleNoFreezer);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 14);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses openedLifeDays for opened items', () => {
|
||||
const openedDate = new Date('2024-01-05');
|
||||
const item = {
|
||||
...baseItem,
|
||||
status: ItemStatus.OPENED,
|
||||
openedDate,
|
||||
};
|
||||
const result = service.calculate(item, rule);
|
||||
const expected = new Date('2024-01-05');
|
||||
expected.setDate(expected.getDate() + 7);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses openedLifeDays for prepared items with openedDate', () => {
|
||||
const openedDate = new Date('2024-01-05');
|
||||
const item = {
|
||||
...baseItem,
|
||||
status: ItemStatus.PREPARED,
|
||||
openedDate,
|
||||
};
|
||||
const result = service.calculate(item, rule);
|
||||
const expected = new Date('2024-01-05');
|
||||
expected.setDate(expected.getDate() + 7);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses shelfLifeDays for opened item without openedDate', () => {
|
||||
const item = { ...baseItem, status: ItemStatus.OPENED };
|
||||
const result = service.calculate(item, rule);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 14);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('uses shelfLifeDays for sealed items', () => {
|
||||
const result = service.calculate(baseItem, rule);
|
||||
const expected = new Date('2024-01-01');
|
||||
expected.setDate(expected.getDate() + 14);
|
||||
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||
});
|
||||
|
||||
it('computes daysRemaining and urgency', () => {
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 10);
|
||||
const item = { ...baseItem, expirationDate: future };
|
||||
const result = service.calculate(item, rule);
|
||||
expect(result.daysRemaining).toBeGreaterThan(5);
|
||||
expect(result.urgency).toBe(FreshnessUrgency.FRESH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('returns true for sealed', () => {
|
||||
expect(service.isActive('sealed')).toBe(true);
|
||||
});
|
||||
it('returns true for opened', () => {
|
||||
expect(service.isActive('opened')).toBe(true);
|
||||
});
|
||||
it('returns true for prepared', () => {
|
||||
expect(service.isActive('prepared')).toBe(true);
|
||||
});
|
||||
it('returns false for consumed', () => {
|
||||
expect(service.isActive('consumed')).toBe(false);
|
||||
});
|
||||
it('returns false for discarded', () => {
|
||||
expect(service.isActive('discarded')).toBe(false);
|
||||
});
|
||||
it('returns false for expired', () => {
|
||||
expect(service.isActive('expired')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapUrgency', () => {
|
||||
it('returns FRESH for > 5 days', () => {
|
||||
expect(service.mapUrgency(6)).toBe(FreshnessUrgency.FRESH);
|
||||
});
|
||||
it('returns USE_SOON for 2-5 days', () => {
|
||||
expect(service.mapUrgency(3)).toBe(FreshnessUrgency.USE_SOON);
|
||||
});
|
||||
it('returns URGENT for 0-1 days', () => {
|
||||
expect(service.mapUrgency(1)).toBe(FreshnessUrgency.URGENT);
|
||||
});
|
||||
it('returns CHECK for -1 to -3 days', () => {
|
||||
expect(service.mapUrgency(-1)).toBe(FreshnessUrgency.CHECK);
|
||||
});
|
||||
it('returns EXPIRED for < -3 days', () => {
|
||||
expect(service.mapUrgency(-4)).toBe(FreshnessUrgency.EXPIRED);
|
||||
});
|
||||
it('returns USE_SOON for exactly 2', () => {
|
||||
expect(service.mapUrgency(2)).toBe(FreshnessUrgency.USE_SOON);
|
||||
});
|
||||
it('returns USE_SOON for exactly 5', () => {
|
||||
expect(service.mapUrgency(5)).toBe(FreshnessUrgency.USE_SOON);
|
||||
});
|
||||
it('returns URGENT for exactly 0', () => {
|
||||
expect(service.mapUrgency(0)).toBe(FreshnessUrgency.URGENT);
|
||||
});
|
||||
it('returns CHECK for exactly -3', () => {
|
||||
expect(service.mapUrgency(-3)).toBe(FreshnessUrgency.CHECK);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockFind,
|
||||
mockFindOne,
|
||||
mockFindOneAndUpdate,
|
||||
mockFindOneAndDelete,
|
||||
mockSave,
|
||||
mockAggregate,
|
||||
mockUpdateMany,
|
||||
mockFindByIdAndUpdate,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockFindOneAndDelete: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
mockAggregate: vi.fn(),
|
||||
mockUpdateMany: vi.fn(),
|
||||
mockFindByIdAndUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/schemas/pantry-item.schema.js', () => {
|
||||
const chain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
|
||||
const findOneChain = () => ({
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindOne,
|
||||
});
|
||||
|
||||
const updateChain = () => ({
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindOneAndUpdate,
|
||||
});
|
||||
|
||||
const deleteChain = () => ({
|
||||
exec: mockFindOneAndDelete,
|
||||
});
|
||||
|
||||
const updateByIdChain = () => ({
|
||||
exec: mockFindByIdAndUpdate,
|
||||
});
|
||||
|
||||
const updateManyChain = () => ({
|
||||
exec: mockUpdateMany,
|
||||
});
|
||||
|
||||
const aggChain = () => ({
|
||||
exec: mockAggregate,
|
||||
});
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) {
|
||||
this.data = data;
|
||||
}
|
||||
save() {
|
||||
mockSave(this.data);
|
||||
return Promise.resolve({ toObject: () => this.data });
|
||||
}
|
||||
static find = vi.fn(() => chain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
static findOneAndDelete = vi.fn(() => deleteChain());
|
||||
static findByIdAndUpdate = vi.fn(() => updateByIdChain());
|
||||
static updateMany = vi.fn(() => updateManyChain());
|
||||
static aggregate = vi.fn(() => aggChain());
|
||||
}
|
||||
|
||||
return { PantryItemModel: FakeModel };
|
||||
});
|
||||
|
||||
import { PantryRepository } from '../../../src/modules/pantry/pantry.repository.js';
|
||||
|
||||
describe(PantryRepository.name, () => {
|
||||
let repo: PantryRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new PantryRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated results', async () => {
|
||||
const items = [{ _id: { toString: () => 'id1' } }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles hasMore', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({
|
||||
_id: { toString: () => `id${i}` },
|
||||
}));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('applies storageLocation filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { storageLocation: 'fridge', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter with single value', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { status: 'sealed', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter with multiple values', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { status: 'sealed,opened', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies urgency filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { urgency: 'urgent,check', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies single urgency filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { urgency: 'urgent', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies productId filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { productId: 'p1', limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies cursor', async () => {
|
||||
const cursor = Buffer.from('abc').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns item', async () => {
|
||||
mockFindOne.mockResolvedValue({ _id: 'id1' });
|
||||
const result = await repo.findById('id1', 'hh1');
|
||||
expect(result).toEqual({ _id: 'id1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExpiringSoon', () => {
|
||||
it('returns items expiring within days', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const result = await repo.findExpiringSoon('hh1', 7);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('supports cursor', async () => {
|
||||
const cursor = Buffer.from('abc').toString('base64');
|
||||
mockFind.mockResolvedValue([]);
|
||||
const result = await repo.findExpiringSoon('hh1', 7, cursor, 20);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByHousehold', () => {
|
||||
it('returns active items', async () => {
|
||||
mockFind.mockResolvedValue([{ _id: 'id1' }]);
|
||||
const result = await repo.findActiveByHousehold('hh1');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns item', async () => {
|
||||
const data = { name: 'test' };
|
||||
const result = await repo.create(data);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns item', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'id1' });
|
||||
const result = await repo.update('id1', 'hh1', { quantity: 3 });
|
||||
expect(result).toEqual({ _id: 'id1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFreshness', () => {
|
||||
it('updates freshness estimate', async () => {
|
||||
mockFindByIdAndUpdate.mockResolvedValue(undefined);
|
||||
await repo.updateFreshness('id1', { urgency: 'fresh' });
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates freshness and status', async () => {
|
||||
mockFindByIdAndUpdate.mockResolvedValue(undefined);
|
||||
await repo.updateFreshness('id1', { urgency: 'expired' }, 'expired');
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes item', async () => {
|
||||
mockFindOneAndDelete.mockResolvedValue({ _id: 'id1' });
|
||||
await repo.delete('id1', 'hh1');
|
||||
expect(mockFindOneAndDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWasteStats', () => {
|
||||
it('returns aggregation result', async () => {
|
||||
mockAggregate.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]);
|
||||
const result = await repo.getWasteStats('hh1', new Date(), new Date());
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopWastedProducts', () => {
|
||||
it('returns top wasted products', async () => {
|
||||
mockAggregate.mockResolvedValue([{ productId: 'p1', productName: 'Milk', count: 3 }]);
|
||||
const result = await repo.getTopWastedProducts('hh1', new Date(), new Date());
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIds', () => {
|
||||
it('returns items by ids', async () => {
|
||||
mockFind.mockResolvedValue([{ _id: 'id1' }]);
|
||||
const result = await repo.findByIds(['id1'], 'hh1');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdateStatus', () => {
|
||||
it('returns modified count', async () => {
|
||||
mockUpdateMany.mockResolvedValue({ modifiedCount: 2 });
|
||||
const result = await repo.bulkUpdateStatus(['id1', 'id2'], 'hh1', 'consumed' as never);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockFindByHousehold,
|
||||
mockFindById,
|
||||
mockFindExpiringSoon,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockGetWasteStats,
|
||||
mockGetTopWastedProducts,
|
||||
mockFindByIds,
|
||||
mockBulkUpdateStatus,
|
||||
mockFindActiveByHousehold,
|
||||
mockUpdateFreshness,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockFindExpiringSoon: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockGetWasteStats: vi.fn(),
|
||||
mockGetTopWastedProducts: vi.fn(),
|
||||
mockFindByIds: vi.fn(),
|
||||
mockBulkUpdateStatus: vi.fn(),
|
||||
mockFindActiveByHousehold: vi.fn(),
|
||||
mockUpdateFreshness: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockProductFindById } = vi.hoisted(() => ({
|
||||
mockProductFindById: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockFindApplicableRule } = vi.hoisted(() => ({
|
||||
mockFindApplicableRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/pantry/pantry.repository.js', () => ({
|
||||
PantryRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findExpiringSoon = mockFindExpiringSoon;
|
||||
findActiveByHousehold = mockFindActiveByHousehold;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
updateFreshness = mockUpdateFreshness;
|
||||
delete = mockDelete;
|
||||
getWasteStats = mockGetWasteStats;
|
||||
getTopWastedProducts = mockGetTopWastedProducts;
|
||||
findByIds = mockFindByIds;
|
||||
bulkUpdateStatus = mockBulkUpdateStatus;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findById = mockProductFindById;
|
||||
findByIds = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/freshness-rules/freshness-rules.repository.js', () => ({
|
||||
FreshnessRulesRepository: class {
|
||||
findApplicableRule = mockFindApplicableRule;
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
create = vi.fn();
|
||||
update = vi.fn();
|
||||
delete = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import pantryRoutes from '../../../src/modules/pantry/pantry.routes.js';
|
||||
|
||||
const freshness = {
|
||||
estimatedExpiryDate: new Date('2024-02-01').toISOString(),
|
||||
daysRemaining: 14,
|
||||
urgency: 'fresh',
|
||||
source: 'rule',
|
||||
};
|
||||
|
||||
function makeItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'item-1',
|
||||
householdId: 'hh1',
|
||||
productId: 'p1',
|
||||
productName: 'Milk',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
purchaseDate: new Date('2024-01-01').toISOString(),
|
||||
status: 'sealed',
|
||||
freshnessEstimate: freshness,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pantry.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(pantryRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /pantry', () => {
|
||||
it('returns paginated list', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [makeItem()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty list', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pantry/expiring-soon', () => {
|
||||
it('returns expiring items', async () => {
|
||||
mockFindExpiringSoon.mockResolvedValue({
|
||||
data: [makeItem()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/expiring-soon',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pantry/stats', () => {
|
||||
it('returns waste stats', async () => {
|
||||
mockGetWasteStats.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]);
|
||||
mockGetTopWastedProducts.mockResolvedValue([]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/stats',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.totalItemsConsumed).toBe(5);
|
||||
expect(body.wastePercentage).toBeCloseTo(28.57, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pantry/:id', () => {
|
||||
it('returns item', async () => {
|
||||
mockFindById.mockResolvedValue(makeItem());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().productName).toBe('Milk');
|
||||
});
|
||||
|
||||
it('returns item with all optional fields', async () => {
|
||||
mockFindById.mockResolvedValue(
|
||||
makeItem({
|
||||
expirationDate: new Date('2024-02-01').toISOString(),
|
||||
openedDate: new Date('2024-01-05').toISOString(),
|
||||
preparedDate: new Date('2024-01-06').toISOString(),
|
||||
notes: 'Organic',
|
||||
purchasePrice: 4.99,
|
||||
storeId: 's1',
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.expirationDate).toBeDefined();
|
||||
expect(body.openedDate).toBeDefined();
|
||||
expect(body.preparedDate).toBeDefined();
|
||||
expect(body.notes).toBe('Organic');
|
||||
expect(body.purchasePrice).toBe(4.99);
|
||||
expect(body.storeId).toBe('s1');
|
||||
});
|
||||
|
||||
it('returns 404 when not found', async () => {
|
||||
mockFindById.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/pantry/missing',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /pantry', () => {
|
||||
it('creates a pantry item', async () => {
|
||||
mockProductFindById.mockResolvedValue({
|
||||
_id: 'p1',
|
||||
name: 'Milk',
|
||||
category: 'dairy',
|
||||
});
|
||||
mockFindApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7 });
|
||||
mockCreate.mockResolvedValue(makeItem());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId: 'p1',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('rejects missing productId', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /pantry/:id', () => {
|
||||
it('updates a pantry item', async () => {
|
||||
mockFindById.mockResolvedValue(makeItem());
|
||||
mockUpdate.mockResolvedValue(makeItem({ quantity: 3 }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ quantity: 3 }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /pantry/:id/transition', () => {
|
||||
it('transitions item status', async () => {
|
||||
const item = makeItem({ status: 'sealed' });
|
||||
mockFindById.mockResolvedValue(item);
|
||||
mockUpdate.mockResolvedValue({ ...item, status: 'consumed' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry/item-1/transition',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'consumed' }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /pantry/batch-transition', () => {
|
||||
it('batch transitions items', async () => {
|
||||
mockFindByIds.mockResolvedValue([makeItem()]);
|
||||
mockBulkUpdateStatus.mockResolvedValue(1);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/pantry/batch-transition',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemIds: ['item-1'],
|
||||
status: 'consumed',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().transitioned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /pantry/:id', () => {
|
||||
it('deletes a pantry item', async () => {
|
||||
mockFindById.mockResolvedValue(makeItem());
|
||||
mockDelete.mockResolvedValue(makeItem());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/pantry/item-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PantryService } from '../../../src/modules/pantry/pantry.service.js';
|
||||
import { NotFoundError, BadRequestError } from '../../../src/common/errors.js';
|
||||
import { ItemStatus } from '@meshitrack/shared';
|
||||
|
||||
const mockPantryRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findExpiringSoon: vi.fn(),
|
||||
findActiveByHousehold: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateFreshness: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getWasteStats: vi.fn(),
|
||||
getTopWastedProducts: vi.fn(),
|
||||
findByIds: vi.fn(),
|
||||
bulkUpdateStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const mockFreshnessRulesRepo = {
|
||||
findApplicableRule: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIds: vi.fn(),
|
||||
};
|
||||
|
||||
function makeItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: { toString: () => 'item-1' },
|
||||
householdId: 'hh1',
|
||||
productId: 'p1',
|
||||
productName: 'Milk',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
purchaseDate: new Date('2024-01-01').toISOString(),
|
||||
status: ItemStatus.SEALED,
|
||||
freshnessEstimate: {
|
||||
estimatedExpiryDate: new Date('2024-01-15').toISOString(),
|
||||
daysRemaining: 14,
|
||||
urgency: 'fresh',
|
||||
source: 'rule',
|
||||
},
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProduct() {
|
||||
return {
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Milk',
|
||||
category: 'dairy',
|
||||
servingSize: 250,
|
||||
servingUnit: 'ml',
|
||||
nutrition: { calories: 60, protein: 3, carbs: 5, fat: 3 },
|
||||
};
|
||||
}
|
||||
|
||||
describe(PantryService.name, () => {
|
||||
let service: PantryService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PantryService({
|
||||
pantryRepository: mockPantryRepo as never,
|
||||
freshnessRulesRepository: mockFreshnessRulesRepo as never,
|
||||
productsRepository: mockProductsRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockPantryRepo.findByHousehold.mockResolvedValue(expected);
|
||||
const result = await service.list('hh1', { limit: 20 });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns item when found', async () => {
|
||||
const item = makeItem();
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
const result = await service.getById('item-1', 'hh1');
|
||||
expect(result).toEqual(item);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockPantryRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a pantry item', async () => {
|
||||
const product = makeProduct();
|
||||
mockProductsRepo.findById.mockResolvedValue(product);
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
});
|
||||
mockPantryRepo.create.mockResolvedValue(makeItem());
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
productId: 'p1',
|
||||
storageLocation: 'fridge' as never,
|
||||
quantity: 1,
|
||||
unit: 'piece' as never,
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(mockPantryRepo.create).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates item with all optional fields', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
|
||||
mockPantryRepo.create.mockResolvedValue(makeItem());
|
||||
|
||||
await service.create(
|
||||
{
|
||||
productId: 'p1',
|
||||
storageLocation: 'fridge' as never,
|
||||
quantity: 2,
|
||||
unit: 'piece' as never,
|
||||
purchaseDate: '2024-01-01T00:00:00Z',
|
||||
expirationDate: '2024-02-01T00:00:00Z',
|
||||
notes: 'Organic',
|
||||
purchasePrice: 4.99,
|
||||
storeId: 's1',
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(mockPantryRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when product not found', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
productId: 'missing',
|
||||
storageLocation: 'fridge' as never,
|
||||
quantity: 1,
|
||||
unit: 'piece' as never,
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns item', async () => {
|
||||
const item = makeItem();
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, quantity: 3 });
|
||||
|
||||
const result = await service.update('item-1', 'hh1', { quantity: 3 });
|
||||
expect((result as Record<string, unknown>).quantity).toBe(3);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockPantryRepo.findById.mockResolvedValue(makeItem());
|
||||
mockPantryRepo.update.mockResolvedValue(null);
|
||||
await expect(service.update('item-1', 'hh1', { quantity: 3 })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transition', () => {
|
||||
it('transitions from sealed to opened', async () => {
|
||||
const item = makeItem({ status: ItemStatus.SEALED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
});
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });
|
||||
|
||||
const result = await service.transition('item-1', 'hh1', { status: 'opened' as never });
|
||||
expect((result as Record<string, unknown>).status).toBe(ItemStatus.OPENED);
|
||||
});
|
||||
|
||||
it('transitions from sealed to consumed', async () => {
|
||||
const item = makeItem({ status: ItemStatus.SEALED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });
|
||||
|
||||
const result = await service.transition('item-1', 'hh1', { status: 'consumed' as never });
|
||||
expect((result as Record<string, unknown>).status).toBe(ItemStatus.CONSUMED);
|
||||
});
|
||||
|
||||
it('transitions from sealed to discarded', async () => {
|
||||
const item = makeItem({ status: ItemStatus.SEALED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.DISCARDED });
|
||||
|
||||
const result = await service.transition('item-1', 'hh1', { status: 'discarded' as never });
|
||||
expect((result as Record<string, unknown>).status).toBe(ItemStatus.DISCARDED);
|
||||
});
|
||||
|
||||
it('transitions from opened to prepared', async () => {
|
||||
const item = makeItem({ status: ItemStatus.OPENED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.PREPARED });
|
||||
|
||||
const result = await service.transition('item-1', 'hh1', { status: 'prepared' as never });
|
||||
expect((result as Record<string, unknown>).status).toBe(ItemStatus.PREPARED);
|
||||
});
|
||||
|
||||
it('rejects invalid transition', async () => {
|
||||
const item = makeItem({ status: ItemStatus.CONSUMED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
await expect(
|
||||
service.transition('item-1', 'hh1', { status: 'opened' as never }),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('includes notes and date in transition', async () => {
|
||||
const item = makeItem({ status: ItemStatus.SEALED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });
|
||||
|
||||
await service.transition('item-1', 'hh1', {
|
||||
status: 'consumed' as never,
|
||||
date: '2024-01-10T12:00:00Z',
|
||||
notes: 'Used in cooking',
|
||||
});
|
||||
|
||||
expect(mockPantryRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
const item = makeItem({ status: ItemStatus.SEALED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockPantryRepo.update.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.transition('item-1', 'hh1', { status: 'consumed' as never }),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('recalculates freshness when opening and product not found', async () => {
|
||||
const item = makeItem({ status: ItemStatus.SEALED });
|
||||
mockPantryRepo.findById.mockResolvedValue(item);
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
|
||||
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });
|
||||
|
||||
await service.transition('item-1', 'hh1', { status: 'opened' as never });
|
||||
expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'other',
|
||||
'fridge',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchTransition', () => {
|
||||
it('transitions valid items', async () => {
|
||||
const items = [
|
||||
makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED }),
|
||||
makeItem({ _id: { toString: () => 'id2' }, status: ItemStatus.OPENED }),
|
||||
];
|
||||
mockPantryRepo.findByIds.mockResolvedValue(items);
|
||||
mockPantryRepo.bulkUpdateStatus.mockResolvedValue(2);
|
||||
|
||||
const result = await service.batchTransition('hh1', {
|
||||
itemIds: ['id1', 'id2'],
|
||||
status: 'consumed' as never,
|
||||
});
|
||||
|
||||
expect(result.transitioned).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
|
||||
it('skips items with invalid transitions', async () => {
|
||||
const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.CONSUMED })];
|
||||
mockPantryRepo.findByIds.mockResolvedValue(items);
|
||||
|
||||
const result = await service.batchTransition('hh1', {
|
||||
itemIds: ['id1'],
|
||||
status: 'consumed' as never,
|
||||
});
|
||||
|
||||
expect(result.transitioned).toBe(0);
|
||||
expect(result.failed).toBe(1);
|
||||
});
|
||||
|
||||
it('passes date and notes as extra', async () => {
|
||||
const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED })];
|
||||
mockPantryRepo.findByIds.mockResolvedValue(items);
|
||||
mockPantryRepo.bulkUpdateStatus.mockResolvedValue(1);
|
||||
|
||||
await service.batchTransition('hh1', {
|
||||
itemIds: ['id1'],
|
||||
status: 'discarded' as never,
|
||||
date: '2024-01-10T00:00:00Z',
|
||||
notes: 'Expired',
|
||||
});
|
||||
|
||||
expect(mockPantryRepo.bulkUpdateStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpiringSoon', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockPantryRepo.findExpiringSoon.mockResolvedValue(expected);
|
||||
const result = await service.getExpiringSoon('hh1', { days: 7, limit: 20 });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWasteStats', () => {
|
||||
it('computes waste stats for a period', async () => {
|
||||
mockPantryRepo.getWasteStats.mockResolvedValue([{ totalConsumed: 8, totalDiscarded: 2 }]);
|
||||
mockPantryRepo.getTopWastedProducts.mockResolvedValue([
|
||||
{ productId: 'p1', productName: 'Milk', count: 2 },
|
||||
]);
|
||||
|
||||
const result = await service.getWasteStats('hh1', { period: 'month' });
|
||||
|
||||
expect(result.totalItemsConsumed).toBe(8);
|
||||
expect(result.totalItemsDiscarded).toBe(2);
|
||||
expect(result.wastePercentage).toBe(20);
|
||||
expect(result.topWastedProducts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles no data', async () => {
|
||||
mockPantryRepo.getWasteStats.mockResolvedValue([]);
|
||||
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getWasteStats('hh1', { period: 'week' });
|
||||
|
||||
expect(result.totalItemsConsumed).toBe(0);
|
||||
expect(result.totalItemsDiscarded).toBe(0);
|
||||
expect(result.wastePercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('handles quarter period', async () => {
|
||||
mockPantryRepo.getWasteStats.mockResolvedValue([]);
|
||||
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
|
||||
const result = await service.getWasteStats('hh1', { period: 'quarter' });
|
||||
expect(result.period.start).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles year period', async () => {
|
||||
mockPantryRepo.getWasteStats.mockResolvedValue([]);
|
||||
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
|
||||
const result = await service.getWasteStats('hh1', { period: 'year' });
|
||||
expect(result.period.start).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAllFreshness', () => {
|
||||
it('refreshes all active items', async () => {
|
||||
const item = makeItem();
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
|
||||
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||
shelfLifeDays: 14,
|
||||
openedLifeDays: 7,
|
||||
});
|
||||
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
|
||||
|
||||
await service.refreshAllFreshness('hh1');
|
||||
|
||||
expect(mockPantryRepo.updateFreshness).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks items as expired when urgency is expired', async () => {
|
||||
const item = makeItem({
|
||||
purchaseDate: new Date('2020-01-01').toISOString(),
|
||||
});
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
|
||||
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||
shelfLifeDays: 1,
|
||||
openedLifeDays: 1,
|
||||
});
|
||||
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
|
||||
|
||||
await service.refreshAllFreshness('hh1');
|
||||
|
||||
const updateCall = mockPantryRepo.updateFreshness.mock.calls[0];
|
||||
expect(updateCall?.[2]).toBe(ItemStatus.EXPIRED);
|
||||
});
|
||||
|
||||
it('handles missing product gracefully', async () => {
|
||||
const item = makeItem();
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
|
||||
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
|
||||
|
||||
await service.refreshAllFreshness('hh1');
|
||||
|
||||
expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'other',
|
||||
'fridge',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes item', async () => {
|
||||
mockPantryRepo.findById.mockResolvedValue(makeItem());
|
||||
mockPantryRepo.delete.mockResolvedValue(makeItem());
|
||||
const result = await service.delete('item-1', 'hh1');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockPantryRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PricesRepository } from '../../../src/modules/prices/prices.repository.js';
|
||||
|
||||
const { mockSave, MockPriceRecordModel } = vi.hoisted(() => {
|
||||
const mockSave = vi.fn();
|
||||
function MockModel(this: { save: typeof mockSave }, data: unknown) {
|
||||
Object.assign(this, data);
|
||||
this.save = mockSave;
|
||||
}
|
||||
Object.assign(MockModel, {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOneAndUpdate: vi.fn(),
|
||||
insertMany: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
});
|
||||
return { mockSave, MockPriceRecordModel: MockModel };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/schemas/price-record.schema.js', () => ({
|
||||
PriceRecordModel: MockPriceRecordModel,
|
||||
}));
|
||||
|
||||
const { PriceRecordModel } = await import('../../../src/schemas/price-record.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(PricesRepository.name, () => {
|
||||
let repo: PricesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new PricesRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns new document toObject', async () => {
|
||||
const data = { householdId: 'h1', productId: 'p1', productName: 'Apple', storeId: 's1', storeName: 'Store', price: 1, currency: 'USD', quantity: 1, unit: 'g', pricePerUnit: 1, date: new Date(), createdBy: 'u1' };
|
||||
mockSave.mockResolvedValue({ toObject: () => ({ ...data, _id: 'id1' }) });
|
||||
|
||||
const result = await repo.create(data);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result._id).toBe('id1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMany', () => {
|
||||
it('inserts multiple records and returns mapped toObjects', async () => {
|
||||
const inputs = [{ price: 1 }, { price: 2 }];
|
||||
const returns = inputs.map((x, idx) => ({ ...x, _id: `id${idx}`, toObject: function() { return this; } }));
|
||||
vi.mocked(PriceRecordModel.insertMany).mockResolvedValue(returns as any);
|
||||
|
||||
const result = await repo.createMany(inputs as any);
|
||||
expect(PriceRecordModel.insertMany).toHaveBeenCalledWith(inputs);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]._id).toBe('id0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProduct', () => {
|
||||
it('applies complex filters and pagination cursor decoding/encoding', async () => {
|
||||
const baseFilter = { householdId: 'h1', productId: 'prod1' };
|
||||
const startDate = new Date('2026-01-01').toISOString();
|
||||
const endDate = new Date('2026-01-10').toISOString();
|
||||
const cursorId = '507f1f77bcf86cd799439011';
|
||||
const cursorStr = Buffer.from(cursorId).toString('base64');
|
||||
|
||||
const mockItems = [
|
||||
{ _id: '607f1f77bcf86cd799439012', price: 10 },
|
||||
{ _id: '607f1f77bcf86cd799439013', price: 12 }
|
||||
];
|
||||
|
||||
const chain = makeChain(mockItems);
|
||||
vi.mocked(PriceRecordModel.find).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findByProduct('h1', 'prod1', {
|
||||
storeId: 'st1',
|
||||
startDate,
|
||||
endDate,
|
||||
cursor: cursorStr,
|
||||
limit: 2
|
||||
});
|
||||
|
||||
expect(PriceRecordModel.find).toHaveBeenCalledWith({
|
||||
householdId: 'h1',
|
||||
productId: 'prod1',
|
||||
storeId: 'st1',
|
||||
date: {
|
||||
$gte: new Date(startDate),
|
||||
$lte: new Date(endDate),
|
||||
},
|
||||
_id: { $lt: cursorId }
|
||||
});
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('correctly indicates hasMore and generates next base64 cursor', async () => {
|
||||
const mockItems = [
|
||||
{ _id: '607f1f77bcf86cd799439011', price: 10 },
|
||||
{ _id: '607f1f77bcf86cd799439012', price: 11 },
|
||||
{ _id: '607f1f77bcf86cd799439013', price: 12 }
|
||||
];
|
||||
|
||||
const chain = makeChain(mockItems);
|
||||
vi.mocked(PriceRecordModel.find).mockReturnValue(chain as any);
|
||||
|
||||
const result = await repo.findByProduct('h1', 'prod1', { limit: 2 });
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBe(Buffer.from('607f1f77bcf86cd799439012').toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStores', () => {
|
||||
it('runs group/aggregate queries ordered by deviance', async () => {
|
||||
const mockAggResult = [
|
||||
{ _id: 's1', storeName: 'Cheap', latestPrice: 10, latestPricePerUnit: 1, currency: 'USD', date: new Date() }
|
||||
];
|
||||
vi.mocked(PriceRecordModel.aggregate).mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(mockAggResult)
|
||||
} as any);
|
||||
|
||||
const result = await repo.compareStores('h1', 'p1');
|
||||
expect(PriceRecordModel.aggregate).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].storeId).toBe('s1');
|
||||
expect(result[0].latestPricePerUnit).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestForProduct', () => {
|
||||
it('queries latest pricing document ordered by date descending', async () => {
|
||||
const chain = makeChain({ _id: 'pr1' });
|
||||
vi.mocked(PriceRecordModel.findOne).mockReturnValue(chain as any);
|
||||
|
||||
await repo.getLatestForProduct('h1', 'p1', 's1');
|
||||
expect(PriceRecordModel.findOne).toHaveBeenCalledWith({ householdId: 'h1', productId: 'p1', storeId: 's1' });
|
||||
expect(chain.sort).toHaveBeenCalledWith({ date: -1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnalytics', () => {
|
||||
it('executes Promise.all parallel pipeline aggregations for periods, buckets, categories, and inflation', async () => {
|
||||
const mockExec = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(PriceRecordModel.aggregate).mockReturnValue({
|
||||
exec: mockExec
|
||||
} as any);
|
||||
|
||||
await repo.getAnalytics('h1');
|
||||
// 4 explicit pipeline calls should have fired in Promise.all + inflation alert
|
||||
expect(PriceRecordModel.aggregate).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
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',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: {},
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCreate = vi.fn();
|
||||
const mockCreateMany = vi.fn();
|
||||
const mockFindByProduct = vi.fn();
|
||||
const mockCompareStores = vi.fn();
|
||||
const mockGetAnalytics = vi.fn();
|
||||
|
||||
vi.mock('../../../src/modules/prices/prices.repository.js', () => ({
|
||||
PricesRepository: class {
|
||||
create = mockCreate;
|
||||
createMany = mockCreateMany;
|
||||
findByProduct = mockFindByProduct;
|
||||
compareStores = mockCompareStores;
|
||||
getAnalytics = mockGetAnalytics;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findById = vi.fn().mockResolvedValue({ name: 'Mock Product' });
|
||||
findByIds = vi.fn().mockResolvedValue([{ _id: 'p1', name: 'Mock Product' }]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/stores/stores.repository.js', () => ({
|
||||
StoresRepository: class {
|
||||
findById = vi.fn().mockResolvedValue({ name: 'Mock Store' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import pricesRoutes from '../../../src/modules/prices/prices.routes.js';
|
||||
|
||||
describe('prices.routes', () => {
|
||||
let app: any;
|
||||
|
||||
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(pricesRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
productId: 'p1',
|
||||
productName: 'Apples',
|
||||
storeId: 's1',
|
||||
storeName: 'Store',
|
||||
price: 10,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
pricePerUnit: 10,
|
||||
date: new Date(),
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /api/v1/households/:householdId/prices', () => {
|
||||
it('records price and returns 201 response', async () => {
|
||||
mockCreate.mockResolvedValue(
|
||||
makeRecord({
|
||||
receiptImageUrl: 'http://test.com/img.jpg',
|
||||
notes: 'Custom notes',
|
||||
date: '2026-05-14T00:00:00.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/prices',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId: 'p1',
|
||||
storeId: 's1',
|
||||
price: 5.99,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.statusCode === 500) {
|
||||
console.log('ERROR PAYLOAD:', res.payload);
|
||||
}
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().productName).toBe('Apples');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/prices/history/:productId', () => {
|
||||
it('returns a paginated envelope of historical pricing data', async () => {
|
||||
mockFindByProduct.mockResolvedValue({
|
||||
data: [makeRecord()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/prices/history/p1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/prices/analytics', () => {
|
||||
it('returns analytical metrics suite with properly formatted dates', async () => {
|
||||
mockGetAnalytics.mockResolvedValue({
|
||||
spendingOverTime: [],
|
||||
averageBasketByStore: [],
|
||||
spendingByCategory: [],
|
||||
priceAlerts: [{ productId: 'p1', productName: 'Bread', storeId: 's1', storeName: 'Store', previousPrice: 2, currentPrice: 2.5, changePercent: 25, date: new Date() }],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/prices/analytics',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (res.statusCode === 500) {
|
||||
console.log('ERROR PAYLOAD:', res.payload);
|
||||
}
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.priceAlerts).toHaveLength(1);
|
||||
expect(typeof body.priceAlerts[0].date).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/prices/bulk', () => {
|
||||
it('records bulk prices and returns 201', async () => {
|
||||
mockCreateMany.mockResolvedValue([makeRecord()]);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/prices/bulk',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
storeId: 's1',
|
||||
items: [{ productId: 'p1', price: 10, quantity: 1, unit: 'piece' }],
|
||||
}),
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json()[0].productName).toBe('Apples');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/prices/compare/:productId', () => {
|
||||
it('returns comparison array', async () => {
|
||||
mockCompareStores.mockResolvedValue([{ storeId: 's1', storeName: 'Store', latestPrice: 10, latestPricePerUnit: 10, currency: 'USD', date: new Date() }]);
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/prices/compare/p1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PricesService } from '../../../src/modules/prices/prices.service.js';
|
||||
import { NotFoundError } from '../../../src/common/errors.js';
|
||||
|
||||
describe('PricesService', () => {
|
||||
let service: PricesService;
|
||||
|
||||
const mockPricesRepo = {
|
||||
create: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
findByProduct: vi.fn(),
|
||||
compareStores: vi.fn(),
|
||||
getAnalytics: vi.fn(),
|
||||
getLatestForProduct: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIds: vi.fn(),
|
||||
};
|
||||
|
||||
const mockStoresRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PricesService({
|
||||
pricesRepository: mockPricesRepo as any,
|
||||
productsRepository: mockProductsRepo as any,
|
||||
storesRepository: mockStoresRepo as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordPrice', () => {
|
||||
it('calculates unit price and persists data on existing linkages', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ name: 'Milk' });
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Target' });
|
||||
mockPricesRepo.create.mockResolvedValue({ _id: 'rec1' });
|
||||
|
||||
const result = await service.recordPrice(
|
||||
{ productId: 'p1', storeId: 's1', price: 4, quantity: 2, unit: 'ml' as any, currency: 'USD' },
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productName: 'Milk',
|
||||
storeName: 'Target',
|
||||
pricePerUnit: 2,
|
||||
})
|
||||
);
|
||||
expect(result._id).toBe('rec1');
|
||||
});
|
||||
|
||||
it('handles zero quantity and defaults date to current when recording price', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ name: 'Bread' });
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
|
||||
mockPricesRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'rec1' }));
|
||||
|
||||
const result = await service.recordPrice(
|
||||
{ productId: 'p2', storeId: 's2', price: 5, quantity: 0, unit: 'g' as any, currency: 'USD' },
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pricePerUnit: 5,
|
||||
date: expect.any(Date),
|
||||
})
|
||||
);
|
||||
expect(result._id).toBe('rec1');
|
||||
});
|
||||
|
||||
it('throws NotFound if product is invalid', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.recordPrice(
|
||||
{ productId: 'p1', storeId: 's1', price: 1, quantity: 1, unit: 'g' as any, currency: 'USD' },
|
||||
'hh1',
|
||||
'u1'
|
||||
)
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordBulkPrices', () => {
|
||||
it('ingests multiple mappings throwing notFound if one catalog match fails', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
|
||||
mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'p1', name: 'Bread' }]);
|
||||
mockPricesRepo.createMany.mockImplementation(args => args);
|
||||
|
||||
const result = await service.recordBulkPrices(
|
||||
{
|
||||
storeId: 's1',
|
||||
items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(mockPricesRepo.createMany).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].productName).toBe('Bread');
|
||||
});
|
||||
|
||||
it('throws NotFoundError if a product is missing from the catalog', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' });
|
||||
mockProductsRepo.findByIds.mockResolvedValue([]); // Missing product
|
||||
await expect(
|
||||
service.recordBulkPrices(
|
||||
{ storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] },
|
||||
'hh1',
|
||||
'u1'
|
||||
)
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if store is missing', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.recordBulkPrices(
|
||||
{ storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] },
|
||||
'hh1',
|
||||
'u1'
|
||||
)
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wrappers (getPriceHistory, compareStores, getAnalytics)', () => {
|
||||
it('delegates to repository correctly', async () => {
|
||||
mockPricesRepo.findByProduct.mockResolvedValue('history');
|
||||
mockPricesRepo.compareStores.mockResolvedValue('compare');
|
||||
mockPricesRepo.getAnalytics.mockResolvedValue('analytics');
|
||||
|
||||
expect(await service.getPriceHistory('p1', 'hh1', { page: 1, limit: 10 })).toBe('history');
|
||||
expect(await service.compareStores('p1', 'hh1')).toBe('compare');
|
||||
expect(await service.getAnalytics('hh1')).toBe('analytics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatePrice', () => {
|
||||
it('returns price from specific store if present', async () => {
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValue({ price: 8 });
|
||||
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
|
||||
expect(val).toBe(8);
|
||||
});
|
||||
|
||||
it('falls back to generic if requested store history is missing', async () => {
|
||||
// First call (restricted to storeId): empty
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null);
|
||||
// Second call (generic): matches
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValueOnce({ price: 12 });
|
||||
|
||||
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
|
||||
expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2);
|
||||
expect(val).toBe(12);
|
||||
});
|
||||
|
||||
it('returns null if generic lookup also fails', async () => {
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValue(null);
|
||||
const val = await service.estimatePrice('prod1', 'hh1', 'storeA');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if no storeId provided and generic lookup fails', async () => {
|
||||
mockPricesRepo.getLatestForProduct.mockResolvedValue(null);
|
||||
const val = await service.estimatePrice('prod1', 'hh1');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BarcodeService } from '../../../src/modules/products/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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCsv, MAX_FILE_SIZE, MAX_ROWS } from '../../../src/modules/products/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');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProductsRepository } from '../../../src/modules/products/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('../../../src/schemas/product.schema.js', () => ({
|
||||
ProductModel: MockProductModel,
|
||||
}));
|
||||
|
||||
const { ProductModel } = await import('../../../src/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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,579 +0,0 @@
|
|||
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('../../../src/modules/products/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('../../../src/modules/products/barcode.service.js', () => ({
|
||||
BarcodeService: class {
|
||||
lookup = mockBarcodeLookup;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import productsRoutes from '../../../src/modules/products/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProductsService } from '../../../src/modules/products/products.service.js';
|
||||
import { NotFoundError, ConflictError } from '../../../src/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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockAggregate, mockSave } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockAggregate: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/schemas/purchase.schema.js', () => {
|
||||
const findChain = () => ({
|
||||
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 });
|
||||
const aggregateChain = () => ({ exec: mockAggregate });
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) {
|
||||
this.data = data;
|
||||
}
|
||||
save = mockSave;
|
||||
toObject() {
|
||||
return this.data;
|
||||
}
|
||||
static find = vi.fn(() => findChain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
static aggregate = vi.fn(() => aggregateChain());
|
||||
}
|
||||
return { PurchaseModel: FakeModel };
|
||||
});
|
||||
|
||||
import { PurchasesRepository } from '../../../src/modules/purchases/purchases.repository.js';
|
||||
|
||||
const makeItem = (overrides = {}) => ({
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Tylenol',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe(PurchasesRepository.name, () => {
|
||||
let repo: PurchasesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new PurchasesRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns plain object', async () => {
|
||||
const data = {
|
||||
householdId: 'hh1',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
status: 'in_cabinet',
|
||||
items: [makeItem()],
|
||||
purchasedAt: new Date(),
|
||||
createdBy: 'u-1',
|
||||
};
|
||||
mockSave.mockResolvedValue({ toObject: () => data });
|
||||
|
||||
const result = await repo.create(data);
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated items without hasMore', async () => {
|
||||
const items = [{ _id: { toString: () => 'p-1' }, householdId: 'hh1' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('returns hasMore and cursor when results exceed limit', async () => {
|
||||
const items = Array.from({ length: 21 }, (_, i) => ({ _id: { toString: () => `p-${i}` } }));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toHaveLength(20);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it('filters by status when provided', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const { PurchaseModel } = await import('../../../src/schemas/purchase.schema.js');
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, status: 'ordered' });
|
||||
|
||||
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'ordered' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by storeId when provided', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const { PurchaseModel } = await import('../../../src/schemas/purchase.schema.js');
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
|
||||
|
||||
expect(PurchaseModel.find).toHaveBeenCalledWith(expect.objectContaining({ storeId: 'st-1' }));
|
||||
});
|
||||
|
||||
it('applies cursor filter when provided', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const cursor = Buffer.from('p-1').toString('base64');
|
||||
const { PurchaseModel } = await import('../../../src/schemas/purchase.schema.js');
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||
|
||||
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: { $lt: 'p-1' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns purchase when found', async () => {
|
||||
const purchase = { _id: 'p-1', householdId: 'hh1' };
|
||||
mockFindOne.mockResolvedValue(purchase);
|
||||
|
||||
const result = await repo.findById('p-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(purchase);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findById('missing', 'hh1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates notes and returns updated doc', async () => {
|
||||
const updated = { _id: 'p-1', notes: 'new note' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.update('p-1', 'hh1', { notes: 'new note' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('returns null when purchase not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.update('missing', 'hh1', {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('includes items in update set when provided', async () => {
|
||||
const updated = { _id: 'p-1' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const { PurchaseModel } = await import('../../../src/schemas/purchase.schema.js');
|
||||
const items = [{ name: 'X', quantity: 1, unit: 'tablet', addedToCabinet: false }];
|
||||
|
||||
await repo.update('p-1', 'hh1', { items } as never);
|
||||
|
||||
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ $set: expect.objectContaining({ items }) }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveAll', () => {
|
||||
it('sets status to in_cabinet and all items addedToCabinet', async () => {
|
||||
const updated = { _id: 'p-1', status: 'in_cabinet' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const { PurchaseModel } = await import('../../../src/schemas/purchase.schema.js');
|
||||
|
||||
const result = await repo.receiveAll('p-1', 'hh1');
|
||||
|
||||
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'p-1', householdId: 'hh1', isDeleted: false },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({ status: 'in_cabinet' }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markItemsAddedToCabinet', () => {
|
||||
it('builds per-index update set and calls findOneAndUpdate', async () => {
|
||||
const updated = { _id: 'p-1', items: [] };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const { PurchaseModel } = await import('../../../src/schemas/purchase.schema.js');
|
||||
|
||||
const result = await repo.markItemsAddedToCabinet('p-1', 'hh1', [0, 2]);
|
||||
|
||||
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'p-1', householdId: 'hh1', isDeleted: false },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({
|
||||
'items.0.addedToCabinet': true,
|
||||
'items.2.addedToCabinet': true,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('sets isDeleted to true', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'p-1', isDeleted: true });
|
||||
|
||||
const result = await repo.softDelete('p-1', 'hh1');
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingMedicineStock', () => {
|
||||
it('returns aggregated stock by medicineId', async () => {
|
||||
const rows = [{ medicineId: 'med-1', totalUnits: 60 }];
|
||||
mockAggregate.mockResolvedValue(rows);
|
||||
|
||||
const result = await repo.getPendingMedicineStock('hh1');
|
||||
|
||||
expect(result).toEqual(rows);
|
||||
});
|
||||
|
||||
it('returns empty array when no pending purchases', async () => {
|
||||
mockAggregate.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.getPendingMedicineStock('hh1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
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 { mockList, mockGetById, mockCreate, mockUpdate, mockReceive, mockDelete } = vi.hoisted(
|
||||
() => ({
|
||||
mockList: vi.fn(),
|
||||
mockGetById: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockReceive: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('../../../src/modules/purchases/purchases.repository.js', () => ({
|
||||
PurchasesRepository: class {
|
||||
create = vi.fn();
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
update = vi.fn();
|
||||
receiveAll = vi.fn();
|
||||
softDelete = vi.fn();
|
||||
getPendingMedicineStock = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/purchases/purchases.service.js', () => ({
|
||||
PurchasesService: class {
|
||||
list = mockList;
|
||||
getById = mockGetById;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
receive = mockReceive;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import purchasesRoutes from '../../../src/modules/purchases/purchases.routes.js';
|
||||
|
||||
function makeFakePurchase(overrides = {}) {
|
||||
return {
|
||||
_id: 'p-1',
|
||||
householdId: 'hh1',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
status: 'in_cabinet',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
purchasedAt: '2026-01-15T00:00:00.000Z',
|
||||
createdBy: 'kc-1',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('purchases.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(purchasesRoutes);
|
||||
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/purchases', () => {
|
||||
it('returns paginated purchase list', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakePurchase()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].storeName).toBe('CVS');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query params to service', async () => {
|
||||
mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases?status=ordered&limit=10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ status: 'ordered', limit: 10 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes ObjectId _id to string', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakePurchase({ _id: { toString: () => 'p-obj' } })],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data[0]._id).toBe('p-obj');
|
||||
});
|
||||
|
||||
it('converts Date objects to ISO strings', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
purchasedAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const item = res.json().data[0];
|
||||
expect(item.purchasedAt).toBe('2026-01-15T00:00:00.000Z');
|
||||
expect(item.createdAt).toBe('2026-01-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('includes optional fields in item response when present', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
notes: 'picked up on the way home',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const item = res.json().data[0].items[0];
|
||||
expect(item.actualPrice).toBe(9.99);
|
||||
expect(item.currency).toBe('USD');
|
||||
expect(res.json().data[0].notes).toBe('picked up on the way home');
|
||||
});
|
||||
|
||||
it('handles item with ObjectId _id and priceRecordId', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
items: [
|
||||
{
|
||||
_id: { toString: () => 'item-obj' },
|
||||
name: 'Advil',
|
||||
quantity: 10,
|
||||
unit: 'tablet',
|
||||
priceRecordId: 'pr-1',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const item = res.json().data[0].items[0];
|
||||
expect(item._id).toBe('item-obj');
|
||||
expect(item.priceRecordId).toBe('pr-1');
|
||||
});
|
||||
|
||||
it('handles item without _id and includes receivedAt on purchase', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
status: 'in_cabinet',
|
||||
receivedAt: '2026-01-20T00:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
name: 'Generic',
|
||||
quantity: 5,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const purchase = res.json().data[0];
|
||||
expect(purchase.items[0]._id).toBe('');
|
||||
expect(purchase.receivedAt).toBe('2026-01-20T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/purchases/:id', () => {
|
||||
it('returns single purchase', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().storeName).toBe('CVS');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases/p-99',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetById).toHaveBeenCalledWith('p-99', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/purchases', () => {
|
||||
const validBody = {
|
||||
storeId: 'st-1',
|
||||
items: [{ name: 'Advil', quantity: 30, unit: 'tablet' }],
|
||||
};
|
||||
|
||||
it('creates purchase and returns 201', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: validBody,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().storeName).toBe('CVS');
|
||||
});
|
||||
|
||||
it('passes body, householdId, and userId to service', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: { ...validBody, status: 'ordered' },
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storeId: 'st-1', status: 'ordered' }),
|
||||
'hh1',
|
||||
'kc-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 for missing storeId', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: { items: [{ name: 'X', quantity: 1, unit: 'tablet' }] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for empty items array', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: { storeId: 'st-1', items: [] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/purchases/:id', () => {
|
||||
it('updates purchase and returns 200', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakePurchase({ notes: 'updated note' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
payload: { notes: 'updated note' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().notes).toBe('updated note');
|
||||
});
|
||||
|
||||
it('passes id, householdId, and body to service', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
payload: { notes: 'note' },
|
||||
});
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'hh1',
|
||||
expect.objectContaining({ notes: 'note' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/purchases/:id/receive', () => {
|
||||
it('returns addedCount and priceRecordsCreated', async () => {
|
||||
mockReceive.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 1 });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases/p-1/receive',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ addedCount: 2, priceRecordsCreated: 1 });
|
||||
});
|
||||
|
||||
it('passes id, householdId, and userId to service', async () => {
|
||||
mockReceive.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 });
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases/p-1/receive',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockReceive).toHaveBeenCalledWith('p-1', 'hh1', 'kc-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/purchases/:id', () => {
|
||||
it('deletes purchase and returns 200', async () => {
|
||||
mockDelete.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('p-1');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockDelete.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PurchasesService } from '../../../src/modules/purchases/purchases.service.js';
|
||||
|
||||
describe(PurchasesService.name, () => {
|
||||
const mockPurchasesRepo = {
|
||||
create: vi.fn(),
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
receiveAll: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
getPendingMedicineStock: vi.fn(),
|
||||
};
|
||||
const mockCabinetService = {
|
||||
addItem: vi.fn(),
|
||||
};
|
||||
const mockStoresRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
const mockPricesRepo = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
let service: PurchasesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PurchasesService({
|
||||
purchasesRepository: mockPurchasesRepo as never,
|
||||
cabinetService: mockCabinetService as never,
|
||||
storesRepository: mockStoresRepo as never,
|
||||
medicineProductsRepository: mockProductsRepo as never,
|
||||
medicinePricesRepository: mockPricesRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
const fakeStore = { _id: 'st-1', name: 'CVS' };
|
||||
const fakeProduct = {
|
||||
_id: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Ibuprofen',
|
||||
brand: 'Advil',
|
||||
};
|
||||
|
||||
describe('create', () => {
|
||||
const validInput = {
|
||||
storeId: 'st-1',
|
||||
status: 'in_cabinet' as const,
|
||||
items: [{ name: 'Advil', quantity: 30, unit: 'tablet', medicineProductId: 'mp-1' }],
|
||||
};
|
||||
|
||||
it('throws NotFoundError when store not found', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow('Store not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when medicine product not found', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Medicine product not found: mp-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates purchase with in_cabinet status and adds items to cabinet', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
||||
mockPurchasesRepo.create.mockResolvedValue(purchase);
|
||||
|
||||
const result = await service.create(validInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
||||
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'in_cabinet', storeName: 'CVS' }),
|
||||
);
|
||||
expect(result).toEqual(purchase);
|
||||
});
|
||||
|
||||
it('records price when actualPrice is set and status is in_cabinet', async () => {
|
||||
const inputWithPrice = {
|
||||
...validInput,
|
||||
items: [{ ...validInput.items[0], actualPrice: 9.99, currency: 'USD' }],
|
||||
};
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(inputWithPrice, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
price: 9.99,
|
||||
medicineName: 'Ibuprofen',
|
||||
storeName: 'CVS',
|
||||
pricePerUnit: expect.closeTo(0.333, 2),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add to cabinet when status is ordered', async () => {
|
||||
const orderedInput = { ...validInput, status: 'ordered' as const };
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1', status: 'ordered' });
|
||||
|
||||
await service.create(orderedInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles item without medicineProductId for in_cabinet', async () => {
|
||||
const noProductInput = {
|
||||
storeId: 'st-1',
|
||||
status: 'in_cabinet' as const,
|
||||
items: [{ name: 'Generic OTC', quantity: 1, unit: 'tablet' }],
|
||||
};
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(noProductInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
expect(mockPurchasesRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses purchasedAt from input when provided', async () => {
|
||||
const inputWithDate = { ...validInput, purchasedAt: '2026-01-15T00:00:00.000Z' };
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(inputWithDate, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ purchasedAt: new Date('2026-01-15T00:00:00.000Z') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses medicineName as brand fallback when brand is undefined', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
const inputWithPrice = {
|
||||
...validInput,
|
||||
items: [{ ...validInput.items[0], actualPrice: 5 }],
|
||||
};
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(inputWithPrice, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receive', () => {
|
||||
it('throws NotFoundError when purchase not found', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Purchase not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestError when status is not ordered', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet', items: [] });
|
||||
|
||||
await expect(service.receive('p-1', 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Purchase is not in ordered status',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds medicine items to cabinet and calls receiveAll', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet' });
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
||||
expect(mockPurchasesRepo.receiveAll).toHaveBeenCalledWith('p-1', 'hh1');
|
||||
expect(result.addedCount).toBe(1);
|
||||
expect(result.priceRecordsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('creates price record when actualPrice is set on item', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledOnce();
|
||||
expect(result.priceRecordsCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('uses medicineName as brand fallback in price record when brand is undefined', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Ibuprofen',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips price record creation when product not found in receive', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date(),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
||||
expect(result.priceRecordsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('skips items already added to cabinet', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date(),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'X',
|
||||
quantity: 10,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
expect(result.addedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockPurchasesRepo.findByHousehold.mockResolvedValue(result);
|
||||
|
||||
const response = await service.list('hh1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockPurchasesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns purchase when found', async () => {
|
||||
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
|
||||
expect(await service.getById('p-1', 'hh1')).toEqual(purchase);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns purchase', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
||||
const updated = { _id: 'p-1', notes: 'updated' };
|
||||
mockPurchasesRepo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('p-1', 'hh1', { notes: 'updated' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when purchase does not exist', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
||||
mockPurchasesRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('p-1', 'hh1', {})).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('soft-deletes and returns purchase', async () => {
|
||||
const deleted = { _id: 'p-1', isDeleted: true };
|
||||
mockPurchasesRepo.softDelete.mockResolvedValue(deleted);
|
||||
|
||||
const result = await service.delete('p-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(deleted);
|
||||
expect(mockPurchasesRepo.softDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when purchase not found', async () => {
|
||||
mockPurchasesRepo.softDelete.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('missing', 'hh1')).rejects.toThrow(
|
||||
'Purchase not found or cannot be deleted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingStockByMedicine', () => {
|
||||
it('returns map of medicineId to totalUnits', async () => {
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
||||
{ medicineId: 'med-1', totalUnits: 60 },
|
||||
{ medicineId: 'med-2', totalUnits: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getPendingStockByMedicine('hh1');
|
||||
|
||||
expect(result.get('med-1')).toBe(60);
|
||||
expect(result.get('med-2')).toBe(30);
|
||||
});
|
||||
|
||||
it('returns empty map when no pending stock', async () => {
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getPendingStockByMedicine('hh1');
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { NutritionCalculatorService } from '../../../src/modules/recipes/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
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('../../../src/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 '../../../src/modules/recipes/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
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('../../../src/modules/recipes/recipes.repository.js', () => ({
|
||||
RecipesRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findByProductId = mockFindByProductId;
|
||||
findAllByProductId = mockFindAllByProductId;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
softDelete = mockSoftDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findByIds = mockFindByIds;
|
||||
findById = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../../src/plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../../src/plugins/household.plugin.js';
|
||||
import usersRoutes from '../../../src/modules/users/users.routes.js';
|
||||
import recipesRoutes from '../../../src/modules/recipes/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RecipesService } from '../../../src/modules/recipes/recipes.service.js';
|
||||
import { NotFoundError, BadRequestError } from '../../../src/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { toMetric } from '../../../src/modules/recipes/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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -176,6 +176,26 @@ describe(RefillsService.name, () => {
|
|||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles zero dailyConsumption gracefully in daysUntilEmptyWithOrders calculation', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
dailyConsumption: 0,
|
||||
totalInCabinet: 10,
|
||||
daysUntilEmpty: 2,
|
||||
},
|
||||
]);
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].daysUntilEmptyWithOrders).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createList', () => {
|
||||
|
|
|
|||
|
|
@ -233,5 +233,24 @@ describe(ShoppingListsRepository.name, () => {
|
|||
expect((repo as any).sortItems(null)).toBeNull();
|
||||
expect((repo as any).sortItems({ name: 'foo' })).toEqual({ name: 'foo' });
|
||||
});
|
||||
|
||||
it('handles missing customName for a and b to achieve 100% branch coverage', () => {
|
||||
const items = [
|
||||
{ id: '2', category: 'Fruit', customName: 'Banana', checked: false },
|
||||
{ id: '1', category: 'Fruit', customName: undefined, checked: false },
|
||||
];
|
||||
const result = (repo as any).sortItems({ items });
|
||||
expect(result.items[0].id).toBe('1'); // undefined/null customName comes before 'Banana'
|
||||
});
|
||||
|
||||
it('handles both customName undefined to tie-break by id', () => {
|
||||
const items = [
|
||||
{ id: 'B', category: 'Fruit', customName: undefined, checked: false },
|
||||
{ id: 'A', category: 'Fruit', customName: undefined, checked: false },
|
||||
];
|
||||
const result = (repo as any).sortItems({ items });
|
||||
expect(result.items[0].id).toBe('A');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,36 +39,9 @@ vi.mock('../../../src/modules/shopping-lists/shopping-lists.repository.js', () =
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/meal-plans/shopping-gap.service.js', () => ({
|
||||
ShoppingGapService: class {
|
||||
calculateGap = vi.fn().mockResolvedValue({ missingItems: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/pantry/pantry.service.js', () => ({
|
||||
PantryService: class {
|
||||
create = vi.fn().mockResolvedValue({ _id: 'pant1' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findById = vi.fn().mockResolvedValue({ category: 'dairy' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/prices/prices.service.js', () => ({
|
||||
PricesService: class {
|
||||
estimatePrice = vi.fn().mockResolvedValue(5.0);
|
||||
recordPrice = vi.fn().mockResolvedValue({});
|
||||
compareStores = vi.fn().mockResolvedValue([]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/modules/meal-plans/meal-plans.repository.js', () => ({
|
||||
MealPlanRepository: class {
|
||||
findById = vi.fn().mockResolvedValue({ _id: 'mp1', weekStartDate: new Date() });
|
||||
update = vi.fn().mockResolvedValue({});
|
||||
vi.mock('../../../src/modules/stores/stores.repository.js', () => ({
|
||||
StoresRepository: class {
|
||||
list = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -189,26 +162,6 @@ describe('shopping-lists.routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists/:id/sync-to-pantry', () => {
|
||||
it('executes batch synchronized promotions resulting in completed summaries', async () => {
|
||||
const populatedList = makeShoppingList({
|
||||
items: [{ id: 'itemA', productId: 'p1', checked: true, addedToPantry: false, quantity: 1, unit: 'piece' }]
|
||||
});
|
||||
mockFindById.mockResolvedValue(populatedList);
|
||||
mockUpdateItem.mockResolvedValue({});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/sync-to-pantry',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.addedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists/:id', () => {
|
||||
it('returns a single shopping list by ID', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList());
|
||||
|
|
@ -296,30 +249,4 @@ describe('shopping-lists.routes', () => {
|
|||
expect(res.json().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/shopping-lists/from-meal-plan/:mealPlanId', () => {
|
||||
it('generates dynamic checklist based on scheduled meal gaps', async () => {
|
||||
mockCreate.mockResolvedValue(makeShoppingList({ _id: 'generatedList1' }));
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/shopping-lists/from-meal-plan/mp1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json()._id).toBe('generatedList1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/shopping-lists/:id/stores', () => {
|
||||
it('returns basket store optimization reports', async () => {
|
||||
mockFindById.mockResolvedValue(makeShoppingList({ items: [{ productId: 'p1' }] }));
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/shopping-lists/list1/stores',
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().singleStoreOptions).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,45 +16,15 @@ describe('ShoppingListsService', () => {
|
|||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGapService = {
|
||||
calculateGap: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPantryService = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPricesService = {
|
||||
estimatePrice: vi.fn(),
|
||||
recordPrice: vi.fn(),
|
||||
compareStores: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMealPlanRepo = {
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ShoppingListsService({
|
||||
shoppingListsRepository: mockListsRepo as any,
|
||||
shoppingGapService: mockGapService as any,
|
||||
pantryService: mockPantryService as any,
|
||||
productsRepository: mockProductsRepo as any,
|
||||
pricesService: mockPricesService as any,
|
||||
mealPlanRepository: mockMealPlanRepo as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('populates initial estimates and auto-generates internal tracking UUIDs', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'produce' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(5);
|
||||
it('populates initial items and auto-generates internal tracking UUIDs', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
|
||||
const result = await service.create(
|
||||
|
|
@ -68,59 +38,13 @@ describe('ShoppingListsService', () => {
|
|||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBeDefined();
|
||||
expect(result.items[0].estimatedPrice).toBe(5);
|
||||
expect(result.totalEstimatedCost).toBe(5);
|
||||
expect(result.items[0].productId).toBe('p1');
|
||||
});
|
||||
|
||||
it('handles missing items and retains explicit categories without hitting product info', async () => {
|
||||
it('handles missing items gracefully during creation', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => Promise.resolve(arg));
|
||||
const resEmpty = await service.create({ name: 'Empty' }, 'hh1', 'u1');
|
||||
expect(resEmpty.items).toEqual([]);
|
||||
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(10);
|
||||
|
||||
const resCategory = await service.create(
|
||||
{
|
||||
name: 'Overridden',
|
||||
items: [{ productId: 'p1', quantity: 1, category: 'bakery', unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
expect(resCategory.items[0].category).toBe('bakery');
|
||||
});
|
||||
|
||||
it('handles missing product info or estimates gracefully during creation', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Minimal run',
|
||||
items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
|
||||
expect(result.items[0].category).toBeUndefined();
|
||||
expect(result.items[0].estimatedPrice).toBeUndefined();
|
||||
expect(result.totalEstimatedCost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles items without productId gracefully during creation', async () => {
|
||||
mockListsRepo.create.mockImplementation(arg => arg);
|
||||
const result = await service.create(
|
||||
{
|
||||
name: 'Custom run',
|
||||
items: [{ customName: 'Bread', quantity: 1, unit: 'pcs' as any }],
|
||||
},
|
||||
'hh1',
|
||||
'u1'
|
||||
);
|
||||
expect(result.items[0].customName).toBe('Bread');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -138,6 +62,12 @@ describe('ShoppingListsService', () => {
|
|||
mockListsRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('returns the list if found', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
const res = await service.getById('list1', 'hh1');
|
||||
expect(res).toEqual({ _id: 'list1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
|
@ -157,10 +87,8 @@ describe('ShoppingListsService', () => {
|
|||
});
|
||||
|
||||
describe('addItem', () => {
|
||||
it('hydrates single product pricing and pushes to list repository', async () => {
|
||||
it('pushes new item to list repository', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
|
||||
mockPricesService.estimatePrice.mockResolvedValue(10);
|
||||
mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });
|
||||
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
|
|
@ -174,47 +102,17 @@ describe('ShoppingListsService', () => {
|
|||
'hh1',
|
||||
expect.objectContaining({
|
||||
productId: 'prodA',
|
||||
estimatedPrice: 10,
|
||||
category: 'meat',
|
||||
})
|
||||
);
|
||||
expect(res.addedItem.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips product info fetch and adds custom items', async () => {
|
||||
mockListsRepo.addItem.mockImplementation((id, hh, data) => Promise.resolve({ _id: id }));
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
customName: 'Custom item',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
});
|
||||
expect(res.addedItem.customName).toBe('Custom item');
|
||||
expect(res.addedItem.productId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError if list update returns null when adding item', async () => {
|
||||
mockListsRepo.addItem.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.addItem('list1', 'hh1', { customName: 'Nonsense', quantity: 1, unit: 'g' as any })
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('handles missing product info or estimates gracefully during addItem', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });
|
||||
|
||||
const res = await service.addItem('list1', 'hh1', {
|
||||
productId: 'prodUnknown',
|
||||
quantity: 1,
|
||||
unit: 'g' as any,
|
||||
category: 'explicit',
|
||||
});
|
||||
|
||||
expect(res.addedItem.category).toBe('explicit');
|
||||
expect(res.addedItem.estimatedPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
|
|
@ -280,213 +178,4 @@ describe('ShoppingListsService', () => {
|
|||
expect(mockListsRepo.delete).toHaveBeenCalledWith('list1', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromMealPlan', () => {
|
||||
it('runs shopping gap report and populates distinct grocery array linked back to source plan', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp1', weekStartDate: '2026-05-18' });
|
||||
mockGapService.calculateGap.mockResolvedValue({
|
||||
missingItems: [
|
||||
{ productId: 'gapProd', missingQuantity: 5, unit: 'g', category: 'dairy' }
|
||||
]
|
||||
});
|
||||
mockPricesService.estimatePrice.mockResolvedValue(2);
|
||||
mockListsRepo.create.mockResolvedValue({ _id: 'newList1' });
|
||||
|
||||
const res = await service.createFromMealPlan('mp1', 'hh1', 'userIdZ');
|
||||
|
||||
expect(mockGapService.calculateGap).toHaveBeenCalledWith('hh1', 'mp1');
|
||||
expect(mockListsRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mealPlanId: 'mp1',
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
productId: 'gapProd',
|
||||
quantity: 5,
|
||||
estimatedPrice: 2,
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
// Assert link-back invocation
|
||||
expect(mockMealPlanRepo.update).toHaveBeenCalledWith('mp1', 'hh1', {
|
||||
shoppingListId: 'newList1',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundError if plan is not found', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.createFromMealPlan('mpMissing', 'hh1', 'u1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('handles missing estimated prices when creating from plan', async () => {
|
||||
mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp2', weekStartDate: '2026-05-18' });
|
||||
mockGapService.calculateGap.mockResolvedValue({
|
||||
missingItems: [{ productId: 'gapProd2', missingQuantity: 3, unit: 'g', category: 'produce' }]
|
||||
});
|
||||
|
||||
mockPricesService.estimatePrice.mockResolvedValue(null);
|
||||
mockListsRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newList2' }));
|
||||
|
||||
const res = await service.createFromMealPlan('mp2', 'hh1', 'u1');
|
||||
expect(res.items[0].estimatedPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCheckedToPantry', () => {
|
||||
it('iterates checked items, creating pantry items and recording actual prices in ledger', async () => {
|
||||
const mockList = {
|
||||
_id: 'list1',
|
||||
preferredStoreId: 'storeA',
|
||||
items: [
|
||||
{
|
||||
id: 'itmA',
|
||||
productId: 'p1',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 2,
|
||||
unit: 'g',
|
||||
actualPrice: 15.50,
|
||||
}
|
||||
]
|
||||
};
|
||||
mockListsRepo.findById.mockResolvedValue(mockList);
|
||||
|
||||
const summary = await service.syncCheckedToPantry('list1', 'hh1', 'userAlpha');
|
||||
|
||||
// 1. Verify pantry promotion
|
||||
expect(mockPantryService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p1',
|
||||
quantity: 2,
|
||||
purchasePrice: 15.50,
|
||||
storeId: 'storeA',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
|
||||
// 2. Verify point-in-time ledger price logging
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p1',
|
||||
price: 15.50,
|
||||
storeId: 'storeA',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
|
||||
// 3. Verify completion bit toggled in list subdocument
|
||||
expect(mockListsRepo.updateItem).toHaveBeenCalledWith('list1', 'hh1', 'itmA', {
|
||||
addedToPantry: true,
|
||||
});
|
||||
|
||||
expect(summary.addedCount).toBe(1);
|
||||
expect(summary.pricesLogged).toBe(1);
|
||||
});
|
||||
|
||||
it('handles item-specific stores and skips pricing logs when no store identifier exists', async () => {
|
||||
const mockList = {
|
||||
_id: 'list2',
|
||||
items: [
|
||||
{
|
||||
id: 'itmB',
|
||||
productId: 'p2',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 1,
|
||||
actualPrice: 10.00,
|
||||
storeId: 'itemStoreB',
|
||||
},
|
||||
{
|
||||
id: 'itmC',
|
||||
productId: 'p3',
|
||||
checked: true,
|
||||
addedToPantry: false,
|
||||
quantity: 1,
|
||||
actualPrice: 5.00,
|
||||
}
|
||||
]
|
||||
};
|
||||
mockListsRepo.findById.mockResolvedValue(mockList);
|
||||
|
||||
const summary = await service.syncCheckedToPantry('list2', 'hh1', 'userAlpha');
|
||||
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledTimes(1);
|
||||
expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productId: 'p2',
|
||||
price: 10.00,
|
||||
storeId: 'itemStoreB',
|
||||
}),
|
||||
'hh1',
|
||||
'userAlpha'
|
||||
);
|
||||
expect(summary.addedCount).toBe(2);
|
||||
expect(summary.pricesLogged).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoreComparison', () => {
|
||||
it('collates individual store deviation lists to rank optimized single store trips', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }]
|
||||
});
|
||||
mockPricesService.compareStores.mockResolvedValue([
|
||||
{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 },
|
||||
{ storeId: 'sB', storeName: 'Whole Foods', latestPrice: 18 },
|
||||
]);
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions).toHaveLength(2);
|
||||
expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart');
|
||||
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10);
|
||||
});
|
||||
|
||||
it('handles missing items in comparison', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }, { productId: 'p2' }]
|
||||
});
|
||||
// Store only has p1, p2 is missing
|
||||
mockPricesService.compareStores.mockImplementation(async (id) => {
|
||||
if (id === 'p1') return [{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions[0].itemsMissing).toContain('p2');
|
||||
});
|
||||
|
||||
it('covers sorting tie breakers and default store name fallbacks', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }]
|
||||
});
|
||||
|
||||
mockPricesService.compareStores.mockResolvedValue([
|
||||
{ storeId: 'sA', storeName: '', latestPrice: 10 },
|
||||
{ storeId: 'sB', storeName: 'Cheaper Store', latestPrice: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(result.singleStoreOptions).toHaveLength(2);
|
||||
|
||||
expect(result.singleStoreOptions[0].storeId).toBe('sB');
|
||||
expect(result.singleStoreOptions[1].storeName).toBe('Store');
|
||||
});
|
||||
|
||||
it('handles stores offering pricing for multiple items in the basket', async () => {
|
||||
mockListsRepo.findById.mockResolvedValue({
|
||||
items: [{ productId: 'p1' }, { productId: 'p2' }]
|
||||
});
|
||||
|
||||
mockPricesService.compareStores.mockImplementation(async (id) => {
|
||||
return [{ storeId: 'sC', storeName: 'Combo Store', latestPrice: id === 'p1' ? 5 : 7 }];
|
||||
});
|
||||
|
||||
const comparison = await service.getStoreComparison('list1', 'hh1');
|
||||
expect(comparison.singleStoreOptions).toHaveLength(1);
|
||||
expect(comparison.singleStoreOptions[0].itemsCovered).toBe(2);
|
||||
expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { ProductModel } from '../../src/schemas/product.schema.js';
|
||||
|
||||
describe(ProductModel.name, () => {
|
||||
it('is a valid mongoose model', () => {
|
||||
expect(ProductModel.modelName).toBe('Product');
|
||||
});
|
||||
|
||||
it('has expected schema paths', () => {
|
||||
const paths = Object.keys(ProductModel.schema.paths);
|
||||
expect(paths).toContain('householdId');
|
||||
expect(paths).toContain('name');
|
||||
expect(paths).toContain('category');
|
||||
expect(paths).toContain('servingSize');
|
||||
expect(paths).toContain('servingUnit');
|
||||
expect(paths).toContain('nutrition');
|
||||
expect(paths).toContain('tags');
|
||||
expect(paths).toContain('source');
|
||||
expect(paths).toContain('createdBy');
|
||||
expect(paths).toContain('deletedAt');
|
||||
expect(paths).toContain('createdAt');
|
||||
expect(paths).toContain('updatedAt');
|
||||
});
|
||||
|
||||
it('has expected indexes defined', () => {
|
||||
const indexes = ProductModel.schema.indexes();
|
||||
const indexKeys = indexes.map(([key]) => Object.keys(key).join(','));
|
||||
expect(indexKeys).toContain('householdId,name,brand,tags');
|
||||
expect(indexKeys).toContain('householdId,deletedAt,category');
|
||||
expect(indexKeys).toContain('householdId,barcode');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue