Tests refactor

This commit is contained in:
Aerilyn Weber 2026-05-19 11:06:03 +09:00
parent 245520fb50
commit 99134d8556
165 changed files with 911 additions and 531 deletions

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import {
AppError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
ConflictError,
BadRequestError,
} from '../../src/common/errors.js';
describe(AppError.name, () => {
it('sets statusCode, error, message, and details', () => {
const details = { field: ['required'] };
const err = new AppError(422, 'Unprocessable', 'Bad input', details);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(422);
expect(err.error).toBe('Unprocessable');
expect(err.message).toBe('Bad input');
expect(err.details).toEqual(details);
});
it('works without details', () => {
const err = new AppError(500, 'Internal', 'Oops');
expect(err.details).toBeUndefined();
});
});
describe(NotFoundError.name, () => {
it('defaults to 404 with standard message', () => {
const err = new NotFoundError();
expect(err.statusCode).toBe(404);
expect(err.error).toBe('Not Found');
expect(err.message).toBe('Resource not found');
});
it('accepts custom message', () => {
const err = new NotFoundError('User not found');
expect(err.message).toBe('User not found');
});
});
describe(UnauthorizedError.name, () => {
it('defaults to 401', () => {
const err = new UnauthorizedError();
expect(err.statusCode).toBe(401);
expect(err.error).toBe('Unauthorized');
expect(err.message).toBe('Unauthorized');
});
});
describe(ForbiddenError.name, () => {
it('defaults to 403', () => {
const err = new ForbiddenError();
expect(err.statusCode).toBe(403);
expect(err.error).toBe('Forbidden');
expect(err.message).toBe('Forbidden');
});
});
describe(ConflictError.name, () => {
it('defaults to 409', () => {
const err = new ConflictError();
expect(err.statusCode).toBe(409);
expect(err.error).toBe('Conflict');
expect(err.message).toBe('Conflict');
});
});
describe(BadRequestError.name, () => {
it('defaults to 400', () => {
const err = new BadRequestError();
expect(err.statusCode).toBe(400);
expect(err.error).toBe('Bad Request');
expect(err.message).toBe('Bad Request');
});
it('accepts message and details', () => {
const details = { name: ['too short'] };
const err = new BadRequestError('Invalid input', details);
expect(err.message).toBe('Invalid input');
expect(err.details).toEqual(details);
});
});

View file

@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import config from '../../src/config/configuration.js';
describe('configuration', () => {
it('exports default config values', () => {
expect(config.port).toBe(3001);
expect(config.mongodb.uri).toContain('mongodb://');
expect(config.keycloak.url).toBe('http://localhost:8080');
expect(config.keycloak.issuerUrl).toBe('http://localhost:8080');
expect(config.keycloak.realm).toBe('meshitrack');
expect(config.keycloak.clientId).toBe('meshitrack-api');
expect(config.cors.origin).toBe('http://localhost:3000');
});
});

View file

@ -0,0 +1,34 @@
import { vi } from 'vitest';
/**
* Dynamically creates a fully mocked repository from a repository class.
* Recursively walks the prototype chain (inheritance-aware) to gather all methods,
* and assigns them a Vitest mock function (vi.fn()).
*
* @param repoClass The repository class constructor to mock
* @returns An object with all methods mocked as vi.fn()
*
* @example
* const mockRepo = createMockRepository(ProductsRepository);
* mockRepo.findById.mockResolvedValue(mockProduct);
*/
export function createMockRepository<T>(
repoClass: new (...args: any[]) => T
): Record<keyof T, any> {
const mock: Record<string, any> = {};
let proto = repoClass.prototype;
while (proto && proto !== Object.prototype) {
const methods = Object.getOwnPropertyNames(proto).filter(
(name) => name !== 'constructor' && typeof (proto as any)[name] === 'function'
);
for (const method of methods) {
if (!(method in mock)) {
mock[method] = vi.fn();
}
}
proto = Object.getPrototypeOf(proto);
}
return mock as Record<keyof T, any>;
}

View file

@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock mongoose to prevent real DB connections; provide class-based models
vi.mock('mongoose', () => {
class FakeSchema {
paths: Record<string, unknown> = {};
constructor(def: Record<string, unknown>, _opts?: unknown) {
for (const key of Object.keys(def)) {
this.paths[key] = { path: key };
}
this.paths['createdAt'] = { path: 'createdAt' };
this.paths['updatedAt'] = { path: 'updatedAt' };
}
index() {
return this;
}
}
const models: Record<string, unknown> = {};
function createFakeModel(name: string) {
const mockExec = vi.fn().mockResolvedValue(null);
const mockLean = vi.fn(() => ({ exec: mockExec }));
class Model {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
return Promise.resolve(this);
}
toObject() {
return { _id: `${name}-id`, ...this._data };
}
static modelName = name;
static schema = new FakeSchema({});
static findOne = vi.fn(() => ({ lean: mockLean }));
static findById = vi.fn(() => ({ lean: mockLean }));
static findOneAndUpdate = vi.fn(() => ({ exec: mockExec }));
static findByIdAndUpdate = vi.fn(() => ({ exec: mockExec }));
}
return Model;
}
return {
default: {
Schema: FakeSchema,
model: vi.fn((name: string, _schema?: unknown) => {
if (!models[name]) models[name] = createFakeModel(name);
return models[name];
}),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
},
};
});
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn(),
}));
import { buildApp } from '../src/main.js';
import * as jose from 'jose';
import { NotFoundError } from '../src/common/errors.js';
describe('buildApp', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates a Fastify app that is ready', async () => {
const app = await buildApp({ logger: false });
await app.ready();
expect(app).toBeDefined();
await app.close();
});
it('has the health route available', async () => {
const app = await buildApp({ logger: false });
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/v1/health' });
expect(res.statusCode).toBe(200);
expect(res.json().status).toBe('ok');
await app.close();
});
});
describe('error handler', () => {
async function getApp() {
// Set up a valid JWT mock for authenticated routes
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {} as never,
} as never);
const app = await buildApp({ logger: false });
// Register test routes that throw various errors
app.get('/test/app-error', { config: { public: true } as never }, async () => {
throw new NotFoundError('Test not found');
});
app.get('/test/generic-error', { config: { public: true } as never }, async () => {
const err = new Error('Something broke');
(err as unknown as Record<string, unknown>).statusCode = 422;
throw err;
});
app.get('/test/unknown-error', { config: { public: true } as never }, async () => {
throw new Error('Unexpected');
});
await app.ready();
return app;
}
beforeEach(() => {
vi.clearAllMocks();
});
it('handles AppError with correct status and body', async () => {
const app = await getApp();
const res = await app.inject({ method: 'GET', url: '/test/app-error' });
expect(res.statusCode).toBe(404);
const body = res.json();
expect(body.error).toBe('Not Found');
expect(body.message).toBe('Test not found');
expect(body.timestamp).toBeDefined();
expect(body.path).toBe('/test/app-error');
await app.close();
});
it('handles generic errors with statusCode', async () => {
const app = await getApp();
const res = await app.inject({ method: 'GET', url: '/test/generic-error' });
expect(res.statusCode).toBe(422);
const body = res.json();
expect(body.error).toBe('Error');
expect(body.message).toBe('Something broke');
await app.close();
});
it('handles unknown 500 errors without leaking messages', async () => {
const app = await getApp();
const res = await app.inject({ method: 'GET', url: '/test/unknown-error' });
expect(res.statusCode).toBe(500);
const body = res.json();
expect(body.error).toBe('Internal Server Error');
expect(body.message).toBe('An unexpected error occurred');
await app.close();
});
it('returns 404 for unknown routes', async () => {
const app = await getApp();
const res = await app.inject({
method: 'GET',
url: '/nonexistent',
headers: {
authorization: 'Bearer test-token',
'x-household-id': 'hh1',
},
});
expect(res.statusCode).toBe(404);
await app.close();
});
});

View file

@ -0,0 +1,378 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockSave, mockInsertMany, mockAggregate } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockSave: vi.fn(),
mockInsertMany: vi.fn(),
mockAggregate: vi.fn(),
}));
vi.mock('../../../src/schemas/cabinet-event.schema.js', () => {
const chain = () => ({
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: mockFind,
});
const aggChain = () => ({
exec: mockAggregate,
});
class FakeModel {
data: unknown;
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static insertMany = mockInsertMany;
static aggregate = vi.fn(() => aggChain());
}
return { CabinetEventModel: FakeModel };
});
import { CabinetEventsRepository } from '../../../src/modules/cabinet-events/cabinet-events.repository.js';
describe(CabinetEventsRepository.name, () => {
let repo: CabinetEventsRepository;
const baseEventData = {
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: 'purchased' as const,
quantity: 10,
quantityBefore: 0,
quantityAfter: 10,
sourceType: 'manual' as const,
};
beforeEach(() => {
vi.clearAllMocks();
repo = new CabinetEventsRepository();
});
describe('create', () => {
it('creates and returns a cabinet event', async () => {
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(baseEventData);
expect(result).toEqual(baseEventData);
expect(mockSave).toHaveBeenCalled();
});
});
describe('createMany', () => {
it('inserts multiple events', async () => {
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
mockInsertMany.mockResolvedValue(events);
const result = await repo.createMany(events);
expect(result).toEqual(events);
expect(mockInsertMany).toHaveBeenCalledWith(events);
});
});
describe('findByHousehold', () => {
it('returns paginated events with no filters', async () => {
const items = [{ _id: 'ev-1', quantity: 10 }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'ev-2', quantity: 5 }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('ev-1').toString('base64');
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ev-${i}`, quantity: 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('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
it('filters by medicineId', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by eventType', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { eventType: 'purchased' as never, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by startDate only', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
startDate: '2024-01-01T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('filters by endDate only', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
endDate: '2024-12-31T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('filters by both startDate and endDate', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
startDate: '2024-01-01T00:00:00.000Z',
endDate: '2024-12-31T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
});
describe('findByCabinetItem', () => {
it('returns paginated events for a cabinet item', async () => {
const items = [{ _id: 'ev-1', cabinetItemId: 'ci-1' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'ev-2' }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('ev-1').toString('base64');
const result = await repo.findByCabinetItem('hh1', 'ci-1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ev-${i}` }));
mockFind.mockResolvedValue(items);
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('getSpendingSummary', () => {
it('returns spending summary with default month period', async () => {
const byMedicine = [
{
_id: 'med-1',
medicineName: 'Metformin',
totalSpent: 100,
totalQuantity: 10,
avgUnitPrice: 10,
purchaseCount: 2,
currency: 'USD',
},
];
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce(byPeriod);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
expect(result.totalSpent).toBe(100);
expect(result.currency).toBe('USD');
expect(result.byMedicine).toHaveLength(1);
expect(result.byMedicine[0].medicineId).toBe('med-1');
expect(result.byMedicine[0].medicineName).toBe('Metformin');
expect(result.byMedicine[0].totalSpent).toBe(100);
expect(result.byMedicine[0].totalQuantity).toBe(10);
expect(result.byMedicine[0].avgUnitPrice).toBe(10);
expect(result.byMedicine[0].purchaseCount).toBe(2);
expect(result.byPeriod).toHaveLength(1);
expect(result.byPeriod[0].period).toBe('2024-01');
expect(result.byPeriod[0].totalSpent).toBe(100);
});
it('returns null currency when no medicine data', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
expect(result.totalSpent).toBe(0);
expect(result.currency).toBeNull();
expect(result.byMedicine).toHaveLength(0);
expect(result.byPeriod).toHaveLength(0);
});
it('filters by medicineId', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('filters by startDate only', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
startDate: '2024-01-01T00:00:00.000Z',
});
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('filters by endDate only', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
endDate: '2024-12-31T00:00:00.000Z',
});
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('filters by both startDate and endDate', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
startDate: '2024-01-01T00:00:00.000Z',
endDate: '2024-12-31T00:00:00.000Z',
});
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('uses quarter date format', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'quarter' });
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('uses year date format', async () => {
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'year' });
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('handles null currency in first medicine entry', async () => {
const byMedicine = [
{
_id: 'med-1',
medicineName: 'Metformin',
totalSpent: 50,
totalQuantity: 5,
avgUnitPrice: 10,
purchaseCount: 1,
currency: null,
},
];
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
expect(result.currency).toBeNull();
});
});
describe('getAvgUnitPriceByMedicine', () => {
it('returns empty map when no medicine ids provided', async () => {
const result = await repo.getAvgUnitPriceByMedicine('hh1', []);
expect(result).toEqual(new Map());
expect(mockAggregate).not.toHaveBeenCalled();
});
it('returns map of avg unit prices', async () => {
const results = [
{ _id: 'med-1', avgUnitPrice: 10.5, currency: 'USD', totalSpent: 105, totalQuantity: 10 },
{ _id: 'med-2', avgUnitPrice: 5.0, currency: 'EUR', totalSpent: 50, totalQuantity: 10 },
];
mockAggregate.mockResolvedValue(results);
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1', 'med-2']);
expect(map.size).toBe(2);
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10.5, currency: 'USD' });
expect(map.get('med-2')).toEqual({ avgUnitPrice: 5.0, currency: 'EUR' });
});
it('handles null currency in results', async () => {
const results = [
{ _id: 'med-1', avgUnitPrice: 10, currency: null, totalSpent: 100, totalQuantity: 10 },
];
mockAggregate.mockResolvedValue(results);
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1']);
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10, currency: null });
});
});
});

View file

@ -0,0 +1,397 @@
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 { mockListEvents, mockGetEventsByItem, mockGetSpendingSummary } = vi.hoisted(() => ({
mockListEvents: vi.fn(),
mockGetEventsByItem: vi.fn(),
mockGetSpendingSummary: vi.fn(),
}));
vi.mock('../../../src/modules/cabinet-events/cabinet-events.repository.js', () => ({
CabinetEventsRepository: class {
create = vi.fn();
createMany = vi.fn();
findByHousehold = vi.fn();
findByCabinetItem = vi.fn();
getSpendingSummary = vi.fn();
getAvgUnitPriceByMedicine = vi.fn();
},
}));
vi.mock('../../../src/modules/cabinet-events/cabinet-events.service.js', () => ({
CabinetEventsService: class {
logEvent = vi.fn();
logEvents = vi.fn();
listEvents = mockListEvents;
getEventsByItem = mockGetEventsByItem;
getSpendingSummary = mockGetSpendingSummary;
getAvgUnitPrices = 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 cabinetEventsRoutes from '../../../src/modules/cabinet-events/cabinet-events.routes.js';
function makeFakeEvent(overrides = {}) {
return {
_id: 'ev-1',
householdId: 'hh1',
userId: 'kc-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: 'purchased',
quantity: 10,
quantityBefore: 0,
quantityAfter: 10,
sourceType: 'manual',
createdAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
describe('cabinet-events.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(cabinetEventsRoutes);
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/cabinet-events', () => {
it('returns paginated event list', async () => {
const event = makeFakeEvent();
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].medicineName).toBe('Metformin');
expect(body.data[0].eventType).toBe('purchased');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const event = makeFakeEvent({
_id: { toString: () => 'ev-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
unitPrice: 5.5,
totalPrice: 55,
currency: 'USD',
storeId: 'store-1',
storeName: 'Pharmacy A',
sourceId: 'src-1',
reason: 'restocking',
notes: 'bulk purchase',
});
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('ev-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].unitPrice).toBe(5.5);
expect(body.data[0].totalPrice).toBe(55);
expect(body.data[0].currency).toBe('USD');
expect(body.data[0].storeId).toBe('store-1');
expect(body.data[0].storeName).toBe('Pharmacy A');
expect(body.data[0].sourceId).toBe('src-1');
expect(body.data[0].reason).toBe('restocking');
expect(body.data[0].notes).toBe('bulk purchase');
});
it('handles Date instances in createdAt', async () => {
const event = makeFakeEvent({
createdAt: new Date('2024-03-15T12:00:00.000Z'),
});
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].createdAt).toBe('2024-03-15T12:00:00.000Z');
});
it('omits null optional fields from response', async () => {
const event = makeFakeEvent({
unitPrice: null,
totalPrice: null,
currency: null,
storeId: null,
storeName: null,
sourceId: null,
reason: null,
notes: null,
});
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].unitPrice).toBeUndefined();
expect(body.data[0].totalPrice).toBeUndefined();
expect(body.data[0].currency).toBeUndefined();
expect(body.data[0].storeId).toBeUndefined();
expect(body.data[0].storeName).toBeUndefined();
expect(body.data[0].sourceId).toBeUndefined();
expect(body.data[0].reason).toBeUndefined();
expect(body.data[0].notes).toBeUndefined();
});
it('passes query parameters to service', async () => {
mockListEvents.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events?medicineId=med-1&eventType=purchased&limit=10',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockListEvents).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({
medicineId: 'med-1',
eventType: 'purchased',
limit: 10,
}),
);
});
});
describe('GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId', () => {
it('returns paginated events for a cabinet item', async () => {
const event = makeFakeEvent();
mockGetEventsByItem.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].cabinetItemId).toBe('ci-1');
expect(body.pagination.hasMore).toBe(false);
});
it('passes query parameters to service', async () => {
mockGetEventsByItem.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1?limit=5&cursor=abc',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockGetEventsByItem).toHaveBeenCalledWith(
'hh1',
'ci-1',
expect.objectContaining({
limit: 5,
cursor: 'abc',
}),
);
});
it('handles ObjectId and Date objects in by-item response', async () => {
const event = makeFakeEvent({
_id: { toString: () => 'ev-obj-2' },
createdAt: new Date('2024-05-01T00:00:00.000Z'),
});
mockGetEventsByItem.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('ev-obj-2');
expect(body.data[0].createdAt).toBe('2024-05-01T00:00:00.000Z');
});
});
describe('GET /api/v1/households/:householdId/cabinet-events/spending-summary', () => {
it('returns spending summary', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 250,
currency: 'USD',
byMedicine: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
totalSpent: 250,
totalQuantity: 25,
avgUnitPrice: 10,
purchaseCount: 5,
},
],
byPeriod: [
{ period: '2024-01', totalSpent: 100 },
{ period: '2024-02', totalSpent: 150 },
],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.totalSpent).toBe(250);
expect(body.currency).toBe('USD');
expect(body.byMedicine).toHaveLength(1);
expect(body.byMedicine[0].medicineId).toBe('med-1');
expect(body.byPeriod).toHaveLength(2);
});
it('passes query parameters to service', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 0,
currency: null,
byMedicine: [],
byPeriod: [],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/spending-summary?period=quarter&medicineId=med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockGetSpendingSummary).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({
period: 'quarter',
medicineId: 'med-1',
}),
);
});
it('returns empty summary with null currency', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 0,
currency: null,
byMedicine: [],
byPeriod: [],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.totalSpent).toBe(0);
expect(body.currency).toBeNull();
expect(body.byMedicine).toHaveLength(0);
expect(body.byPeriod).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CabinetEventsService } from '../../../src/modules/cabinet-events/cabinet-events.service.js';
describe(CabinetEventsService.name, () => {
const mockCabinetEventsRepo = {
create: vi.fn(),
createMany: vi.fn(),
findByHousehold: vi.fn(),
findByCabinetItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPriceByMedicine: vi.fn(),
};
let service: CabinetEventsService;
const baseEventData = {
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: 'purchased' as const,
quantity: 10,
quantityBefore: 0,
quantityAfter: 10,
sourceType: 'manual' as const,
};
beforeEach(() => {
vi.clearAllMocks();
service = new CabinetEventsService({
cabinetEventsRepository: mockCabinetEventsRepo as never,
});
});
describe('logEvent', () => {
it('delegates to repository create', async () => {
const created = { _id: 'ev-1', ...baseEventData };
mockCabinetEventsRepo.create.mockResolvedValue(created);
const result = await service.logEvent(baseEventData);
expect(result).toEqual(created);
expect(mockCabinetEventsRepo.create).toHaveBeenCalledWith(baseEventData);
});
});
describe('logEvents', () => {
it('delegates to repository createMany', async () => {
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
const inserted = events.map((e, i) => ({ _id: `ev-${i}`, ...e }));
mockCabinetEventsRepo.createMany.mockResolvedValue(inserted);
const result = await service.logEvents(events);
expect(result).toEqual(inserted);
expect(mockCabinetEventsRepo.createMany).toHaveBeenCalledWith(events);
});
it('returns empty array for empty input without calling repository', async () => {
const result = await service.logEvents([]);
expect(result).toEqual([]);
expect(mockCabinetEventsRepo.createMany).not.toHaveBeenCalled();
});
});
describe('listEvents', () => {
it('delegates to repository findByHousehold', async () => {
const expected = {
data: [{ _id: 'ev-1' }],
pagination: { cursor: null, hasMore: false },
};
mockCabinetEventsRepo.findByHousehold.mockResolvedValue(expected);
const query = { limit: 20 };
const result = await service.listEvents('hh1', query);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.findByHousehold).toHaveBeenCalledWith('hh1', query);
});
});
describe('getEventsByItem', () => {
it('delegates to repository findByCabinetItem', async () => {
const expected = {
data: [{ _id: 'ev-1', cabinetItemId: 'ci-1' }],
pagination: { cursor: null, hasMore: false },
};
mockCabinetEventsRepo.findByCabinetItem.mockResolvedValue(expected);
const query = { limit: 20 };
const result = await service.getEventsByItem('hh1', 'ci-1', query);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.findByCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1', query);
});
it('passes cursor through to repository', async () => {
const expected = {
data: [],
pagination: { cursor: null, hasMore: false },
};
mockCabinetEventsRepo.findByCabinetItem.mockResolvedValue(expected);
const query = { cursor: 'abc123', limit: 10 };
await service.getEventsByItem('hh1', 'ci-1', query);
expect(mockCabinetEventsRepo.findByCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1', query);
});
});
describe('getSpendingSummary', () => {
it('delegates to repository getSpendingSummary', async () => {
const expected = {
totalSpent: 100,
currency: 'USD',
byMedicine: [],
byPeriod: [],
};
mockCabinetEventsRepo.getSpendingSummary.mockResolvedValue(expected);
const query = { period: 'month' as const };
const result = await service.getSpendingSummary('hh1', query);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.getSpendingSummary).toHaveBeenCalledWith('hh1', query);
});
});
describe('getAvgUnitPrices', () => {
it('delegates to repository getAvgUnitPriceByMedicine', async () => {
const expected = new Map([['med-1', { avgUnitPrice: 10, currency: 'USD' }]]);
mockCabinetEventsRepo.getAvgUnitPriceByMedicine.mockResolvedValue(expected);
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', [
'med-1',
]);
});
});
});

View file

@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave, mockCountDocuments, mockAggregate } =
vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
mockCountDocuments: vi.fn(),
mockAggregate: vi.fn(),
}));
vi.mock('../../../src/schemas/cabinet-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 = () => ({
exec: mockFindOneAndUpdate,
});
const countChain = () => ({
exec: mockCountDocuments,
});
const aggChain = () => ({
exec: mockAggregate,
});
class FakeModel {
data: unknown;
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
static countDocuments = vi.fn(() => countChain());
static aggregate = vi.fn(() => aggChain());
}
return { CabinetItemModel: FakeModel };
});
import { CabinetRepository } from '../../../src/modules/cabinet/cabinet.repository.js';
describe(CabinetRepository.name, () => {
let repo: CabinetRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new CabinetRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'ci-1', quantity: 30 }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'ci-2', quantity: 10 }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('ci-1').toString('base64');
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ci-${i}`, quantity: 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('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
});
it('filters by medicineId', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by status', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
status: 'active' as never,
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('filters by expiringWithin', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { expiringWithin: 30, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
});
describe('findById', () => {
it('returns item by id and householdId', async () => {
const item = { _id: 'ci-1', householdId: 'hh1', quantity: 30 };
mockFindOne.mockResolvedValue(item);
const result = await repo.findById('ci-1', 'hh1');
expect(result).toEqual(item);
});
});
describe('getAggregateSummary', () => {
it('returns aggregate data', async () => {
const aggregated = [{ _id: 'med-1', totalQuantity: 60, itemCount: 2 }];
mockAggregate.mockResolvedValue(aggregated);
const result = await repo.getAggregateSummary('hh1');
expect(result).toEqual(aggregated);
});
});
describe('create', () => {
it('creates and returns cabinet item', async () => {
const data = {
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
quantity: 30,
unit: 'tablet' as const,
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data, 'hh1', 'user-1');
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('update', () => {
it('updates and returns item', async () => {
const updated = { _id: 'ci-1', quantity: 25 };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.update('ci-1', 'hh1', { quantity: 25 });
expect(result).toEqual(updated);
});
});
describe('adjustQuantity', () => {
it('adjusts quantity and returns updated item', async () => {
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 30, status: 'active' });
const updated = { _id: 'ci-1', quantity: 27, status: 'active' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.adjustQuantity('ci-1', 'hh1', -3);
expect(result).toEqual(updated);
});
it('floors quantity at 0 and sets depleted status', async () => {
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 2, status: 'active' });
const updated = { _id: 'ci-1', quantity: 0, status: 'depleted' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.adjustQuantity('ci-1', 'hh1', -5);
expect(result).toEqual(updated);
});
it('re-activates depleted item when adding stock', async () => {
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 0, status: 'depleted' });
const updated = { _id: 'ci-1', quantity: 10, status: 'active' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.adjustQuantity('ci-1', 'hh1', 10);
expect(result).toEqual(updated);
});
it('returns null if item not found', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.adjustQuantity('ci-missing', 'hh1', 5);
expect(result).toBeNull();
});
});
describe('findExpiringSoon', () => {
it('returns items expiring within N days', async () => {
const items = [{ _id: 'ci-1', expirationDate: new Date() }];
mockFind.mockResolvedValue(items);
const result = await repo.findExpiringSoon('hh1', 30);
expect(result).toEqual(items);
});
});
describe('countByMedicineId', () => {
it('returns count', async () => {
mockCountDocuments.mockResolvedValue(3);
const result = await repo.countByMedicineId('med-1');
expect(result).toBe(3);
});
});
describe('softDelete', () => {
it('soft deletes and returns item', async () => {
const deleted = { _id: 'ci-1', isDeleted: true };
mockFindOneAndUpdate.mockResolvedValue(deleted);
const result = await repo.softDelete('ci-1', 'hh1');
expect(result).toEqual(deleted);
});
});
describe('discard', () => {
it('zeros quantity, marks depleted and deleted', async () => {
const discarded = { _id: 'ci-1', quantity: 0, status: 'depleted', isDeleted: true };
mockFindOneAndUpdate.mockResolvedValue(discarded);
const result = await repo.discard('ci-1', 'hh1');
expect(result).toEqual(discarded);
});
it('returns null when item not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.discard('ci-missing', 'hh1');
expect(result).toBeNull();
});
});
describe('findActiveByMedicineForFEFO', () => {
it('returns active items sorted by expiration date', async () => {
const items = [
{ _id: 'ci-1', quantity: 10, expirationDate: new Date('2025-06-01') },
{ _id: 'ci-2', quantity: 20, expirationDate: new Date('2025-12-01') },
];
mockFind.mockResolvedValue(items);
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
expect(result).toEqual(items);
});
it('returns empty array when no active items exist', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
expect(result).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,474 @@
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 { DosageUnit, MedicineForm, StrengthUnit, CabinetItemStatus } 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,
mockGetAggregateSummary,
mockCreate,
mockUpdate,
mockAdjustQuantity,
mockFindExpiringSoon,
mockSoftDelete,
mockCountByMedicineId,
mockDiscard,
mockFindActiveByMedicineForFEFO,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockGetAggregateSummary: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockAdjustQuantity: vi.fn(),
mockFindExpiringSoon: vi.fn(),
mockSoftDelete: vi.fn(),
mockCountByMedicineId: vi.fn(),
mockDiscard: vi.fn(),
mockFindActiveByMedicineForFEFO: vi.fn(),
}));
vi.mock('../../../src/modules/cabinet/cabinet.repository.js', () => ({
CabinetRepository: class {
findByHousehold = mockFindByHousehold;
findById = mockFindById;
getAggregateSummary = mockGetAggregateSummary;
create = mockCreate;
update = mockUpdate;
adjustQuantity = mockAdjustQuantity;
findExpiringSoon = mockFindExpiringSoon;
softDelete = mockSoftDelete;
countByMedicineId = mockCountByMedicineId;
discard = mockDiscard;
findActiveByMedicineForFEFO = mockFindActiveByMedicineForFEFO;
},
}));
const { mockMedicineFindById } = vi.hoisted(() => ({
mockMedicineFindById: vi.fn(),
}));
vi.mock('../../../src/modules/medicines/medicines.repository.js', () => ({
MedicinesRepository: class {
findById = mockMedicineFindById;
findByHousehold = vi.fn();
findDuplicate = vi.fn();
create = vi.fn();
update = vi.fn();
softDelete = vi.fn();
},
}));
const { mockProductFindById } = vi.hoisted(() => ({
mockProductFindById: vi.fn(),
}));
vi.mock('../../../src/modules/medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
findById = mockProductFindById;
findByMedicine = vi.fn();
create = vi.fn();
update = vi.fn();
softDelete = vi.fn();
countByMedicineId = vi.fn();
},
}));
vi.mock('../../../src/modules/medicine-products/medicine-products.service.js', () => ({
MedicineProductsService: class {
listByMedicine = vi.fn();
getById = vi.fn();
create = vi.fn();
update = vi.fn();
delete = vi.fn();
},
}));
vi.mock('../../../src/modules/medicines/medicines.service.js', () => ({
MedicinesService: class {
list = vi.fn();
getById = vi.fn();
create = vi.fn();
update = vi.fn();
delete = vi.fn();
},
}));
vi.mock('../../../src/modules/cabinet-events/cabinet-events.repository.js', () => ({
CabinetEventsRepository: class {
create = vi.fn();
createMany = vi.fn();
findByHousehold = vi.fn();
findByCabinetItem = vi.fn();
getSpendingSummary = vi.fn();
getAvgUnitPriceByMedicine = vi.fn();
},
}));
vi.mock('../../../src/modules/cabinet-events/cabinet-events.service.js', () => ({
CabinetEventsService: class {
logEvent = vi.fn();
logEvents = vi.fn();
listEvents = vi.fn();
getEventsByItem = vi.fn();
getSpendingSummary = vi.fn();
getAvgUnitPrices = 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 medicinesRoutes from '../../../src/modules/medicines/medicines.routes.js';
import medicineProductsRoutes from '../../../src/modules/medicine-products/medicine-products.routes.js';
import cabinetEventsRoutes from '../../../src/modules/cabinet-events/cabinet-events.routes.js';
import cabinetRoutes from '../../../src/modules/cabinet/cabinet.routes.js';
function makeFakeCabinetItem(overrides = {}) {
return {
_id: 'ci-1',
householdId: 'hh1',
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: StrengthUnit.MG,
medicineForm: MedicineForm.TABLET,
quantity: 30,
unit: DosageUnit.TABLET,
status: CabinetItemStatus.ACTIVE,
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('cabinet.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(medicinesRoutes);
await instance.register(medicineProductsRoutes);
await instance.register(cabinetEventsRoutes);
await instance.register(cabinetRoutes);
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/cabinet', () => {
it('returns paginated list', async () => {
const item = makeFakeCabinetItem();
mockFindByHousehold.mockResolvedValue({
data: [item],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].medicineName).toBe('Metformin');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const item = makeFakeCabinetItem({
_id: { toString: () => 'ci-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
expirationDate: new Date('2026-06-01T00:00:00.000Z'),
notes: 'Main supply',
medicineProductId: 'prod-1',
medicineProductBrand: 'Glucophage',
});
mockFindByHousehold.mockResolvedValue({
data: [item],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('ci-obj');
expect(body.data[0].expirationDate).toBe('2026-06-01T00:00:00.000Z');
expect(body.data[0].medicineProductBrand).toBe('Glucophage');
});
it('handles string dates in response', async () => {
const item = makeFakeCabinetItem({
expirationDate: '2026-12-31T00:00:00.000Z',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
});
mockFindByHousehold.mockResolvedValue({
data: [item],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].expirationDate).toBe('2026-12-31T00:00:00.000Z');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
});
it('includes optional purchase and store fields when present', async () => {
const item = makeFakeCabinetItem({
concentration: 5.0,
concentrationUnit: 'mg/mL',
purchaseDate: new Date('2024-03-01T00:00:00.000Z'),
unitPrice: 1.5,
totalPrice: 45.0,
currency: 'USD',
storeId: 'store-1',
storeName: 'Pharmacy Plus',
});
mockFindByHousehold.mockResolvedValue({
data: [item],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].concentration).toBe(5.0);
expect(body.data[0].concentrationUnit).toBe('mg/mL');
expect(body.data[0].purchaseDate).toBe('2024-03-01T00:00:00.000Z');
expect(body.data[0].unitPrice).toBe(1.5);
expect(body.data[0].totalPrice).toBe(45.0);
expect(body.data[0].currency).toBe('USD');
expect(body.data[0].storeId).toBe('store-1');
expect(body.data[0].storeName).toBe('Pharmacy Plus');
});
});
describe('GET /api/v1/households/:householdId/cabinet/summary', () => {
it('returns aggregate summary', async () => {
mockGetAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 60,
unit: 'tablet',
earliestExpiry: new Date('2026-06-01T00:00:00.000Z'),
itemCount: 2,
},
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet/summary',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].totalQuantity).toBe(60);
});
});
describe('GET /api/v1/households/:householdId/cabinet/expiring-soon', () => {
it('returns items expiring within N days', async () => {
mockFindExpiringSoon.mockResolvedValue([makeFakeCabinetItem()]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet/expiring-soon?days=30',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
});
});
describe('GET /api/v1/households/:householdId/cabinet/:id', () => {
it('returns a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(30);
});
});
describe('POST /api/v1/households/:householdId/cabinet', () => {
it('creates a cabinet item', async () => {
mockMedicineFindById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockCreate.mockResolvedValue(makeFakeCabinetItem());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
payload: {
medicineId: 'med-1',
quantity: 30,
unit: DosageUnit.TABLET,
},
});
expect(res.statusCode).toBe(201);
expect(res.json().medicineName).toBe('Metformin');
});
});
describe('PATCH /api/v1/households/:householdId/cabinet/:id', () => {
it('updates a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem());
mockUpdate.mockResolvedValue(makeFakeCabinetItem({ quantity: 25 }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/cabinet/ci-1',
headers: authHeaders,
payload: { quantity: 25 },
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(25);
});
});
describe('POST /api/v1/households/:householdId/cabinet/:id/adjust', () => {
it('adjusts quantity', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem({ quantity: 30 }));
mockAdjustQuantity.mockResolvedValue(makeFakeCabinetItem({ quantity: 27 }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet/ci-1/adjust',
headers: authHeaders,
payload: { delta: -3 },
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(27);
});
});
describe('DELETE /api/v1/households/:householdId/cabinet/:id', () => {
it('soft deletes a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem());
mockSoftDelete.mockResolvedValue(makeFakeCabinetItem({ isDeleted: true }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/cabinet/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
describe('POST /api/v1/households/:householdId/cabinet/:id/discard', () => {
it('discards a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem({ quantity: 20 }));
mockDiscard.mockResolvedValue(makeFakeCabinetItem({ quantity: 0, isDeleted: true }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet/ci-1/discard',
headers: authHeaders,
payload: { reason: 'expired' },
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(0);
});
it('returns 400 for missing reason', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet/ci-1/discard',
headers: authHeaders,
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
});

View file

@ -0,0 +1,500 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CabinetService } from '../../../src/modules/cabinet/cabinet.service.js';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
describe(CabinetService.name, () => {
const mockCabinetRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
getAggregateSummary: vi.fn(),
create: vi.fn(),
update: vi.fn(),
adjustQuantity: vi.fn(),
findExpiringSoon: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
discard: vi.fn(),
findActiveByMedicineForFEFO: vi.fn(),
};
const mockMedicinesRepo = {
findById: vi.fn(),
findByHousehold: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockProductsRepo = {
findById: vi.fn(),
findByMedicine: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
};
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: CabinetService;
beforeEach(() => {
vi.clearAllMocks();
service = new CabinetService({
cabinetRepository: mockCabinetRepo as never,
medicinesRepository: mockMedicinesRepo as never,
medicineProductsRepository: mockProductsRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockCabinetRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', { limit: 20 });
expect(response).toEqual(result);
expect(mockCabinetRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
});
});
describe('getById', () => {
it('returns item when found', async () => {
const item = { _id: 'ci-1', quantity: 30 };
mockCabinetRepo.findById.mockResolvedValue(item);
const result = await service.getById('ci-1', 'hh1');
expect(result).toEqual(item);
});
it('throws NotFoundError when not found', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.getById('ci-missing', 'hh1')).rejects.toThrow('Cabinet item not found');
});
});
describe('getSummary', () => {
it('returns aggregated summary with formatted dates', async () => {
const expiryDate = new Date('2026-06-01T00:00:00.000Z');
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 60,
unit: 'tablet',
earliestExpiry: expiryDate,
itemCount: 2,
},
]);
const result = await service.getSummary('hh1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z');
});
it('handles null expiry dates', async () => {
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Test',
medicineStrength: 10,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 30,
unit: 'tablet',
earliestExpiry: null,
itemCount: 1,
},
]);
const result = await service.getSummary('hh1');
expect(result[0].earliestExpiry).toBeNull();
});
});
describe('addItem', () => {
const createInput = {
medicineId: 'med-1',
quantity: 30,
unit: 'tablet' as const,
};
it('creates item with denormalized medicine fields', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
const created = { _id: 'ci-1', ...createInput, medicineName: 'Metformin' };
mockCabinetRepo.create.mockResolvedValue(created);
const result = await service.addItem(createInput, 'hh1', 'user-1');
expect(result).toEqual(created);
expect(mockCabinetRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
}),
'hh1',
'user-1',
);
});
it('logs PURCHASED event after creation', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
await service.addItem(createInput, 'hh1', 'user-1');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
eventType: CabinetEventType.PURCHASED,
quantity: 30,
quantityBefore: 0,
quantityAfter: 30,
sourceType: CabinetEventSourceType.MANUAL,
}),
);
});
it('throws NotFoundError when medicine not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.addItem(createInput, 'hh1', 'user-1')).rejects.toThrow(
'Medicine not found',
);
});
it('denormalizes product brand when medicineProductId given', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockProductsRepo.findById.mockResolvedValue({
_id: 'prod-1',
brand: 'Glucophage',
});
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
await service.addItem({ ...createInput, medicineProductId: 'prod-1' }, 'hh1', 'user-1');
expect(mockCabinetRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ medicineProductBrand: 'Glucophage' }),
'hh1',
'user-1',
);
});
it('throws NotFoundError when product not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockProductsRepo.findById.mockResolvedValue(null);
await expect(
service.addItem({ ...createInput, medicineProductId: 'prod-missing' }, 'hh1', 'user-1'),
).rejects.toThrow('Medicine product not found');
});
});
describe('update', () => {
it('updates and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 25 };
mockCabinetRepo.update.mockResolvedValue(updated);
const result = await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
expect(result).toEqual(updated);
});
it('logs ADJUSTED event when quantity changes', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.ADJUSTED,
quantity: -5,
quantityBefore: 30,
quantityAfter: 25,
}),
);
});
it('does not log event when quantity unchanged', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
expect(mockCabinetEventsService.logEvent).not.toHaveBeenCalled();
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.update('ci-missing', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when update returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue(null);
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('adjustQuantity', () => {
it('adjusts quantity and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 27 };
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
const result = await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1');
expect(result).toEqual(updated);
});
it('logs ADJUSTED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.ADJUSTED,
quantity: -3,
quantityBefore: 30,
quantityAfter: 27,
reason: 'took some',
}),
);
});
it('throws BadRequestError when delta is 0', async () => {
await expect(service.adjustQuantity('ci-1', 'hh1', 0, 'user-1')).rejects.toThrow(
'Delta must be non-zero',
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-missing', 'hh1', 5, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when adjust returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('getExpiringSoon', () => {
it('delegates to repository', async () => {
const items = [{ _id: 'ci-1' }];
mockCabinetRepo.findExpiringSoon.mockResolvedValue(items);
const result = await service.getExpiringSoon('hh1', 30);
expect(result).toEqual(items);
expect(mockCabinetRepo.findExpiringSoon).toHaveBeenCalledWith('hh1', 30);
});
});
describe('delete', () => {
it('soft deletes item and logs DELETED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
const result = await service.delete('ci-1', 'hh1', 'user-1');
expect(result.isDeleted).toBe(true);
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.DELETED,
quantity: -10,
quantityBefore: 10,
quantityAfter: 0,
}),
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 5,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('discard', () => {
it('discards item and logs DISCARDED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 20,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
expect(result.quantity).toBe(0);
expect(result.isDeleted).toBe(true);
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.DISCARDED,
quantity: -20,
quantityBefore: 20,
quantityAfter: 0,
reason: 'expired',
notes: 'smelled off',
}),
);
});
it('throws BadRequestError when quantity is zero', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 0,
medicineId: 'med-1',
medicineName: 'Test',
});
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cannot discard an item with zero quantity',
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.discard('ci-missing', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when discard returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue(null);
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cabinet item not found',
);
});
});
});

View file

@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockFind,
mockFindOne,
mockFindOneAndUpdate,
mockFindOneAndDelete,
mockSave,
mockFindById,
} = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockFindOneAndDelete: vi.fn(),
mockSave: vi.fn(),
mockFindById: vi.fn(),
}));
vi.mock('../../../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();
});
});
});

View file

@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { mockFindByHousehold, mockFindById, mockCreate, mockUpdate, mockDelete } = vi.hoisted(
() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
}),
);
vi.mock('../../../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);
});
});
});

View file

@ -0,0 +1,137 @@
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);
});
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import healthRoutes from '../../../src/modules/health/health.routes.js';
describe('Health Routes', () => {
async function buildTestApp() {
const app = Fastify({ logger: false });
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(healthRoutes);
return app;
}
it('GET /api/v1/health returns 200 with status ok', async () => {
const app = await buildTestApp();
const response = await app.inject({
method: 'GET',
url: '/api/v1/health',
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body).toMatchObject({
status: 'ok',
version: expect.any(String),
uptime: expect.any(Number),
});
});
it('GET /api/v1/health returns increasing uptime', async () => {
const app = await buildTestApp();
const first = await app.inject({ method: 'GET', url: '/api/v1/health' });
const second = await app.inject({ method: 'GET', url: '/api/v1/health' });
expect(second.json().uptime).toBeGreaterThanOrEqual(first.json().uptime);
});
});

View file

@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HouseholdRole } from '@meshitrack/shared';
const { _mockLean, mockExec, mockFindById, mockFindOne, mockFindByIdAndUpdate, mockSave } =
vi.hoisted(() => {
const mockExec = vi.fn();
const mockLean = vi.fn(() => ({ exec: mockExec }));
return {
mockExec,
mockLean,
mockFindById: vi.fn(() => ({ lean: mockLean })),
mockFindOne: vi.fn(() => ({ lean: mockLean })),
mockFindByIdAndUpdate: vi.fn(() => ({ exec: mockExec })),
mockSave: vi.fn(),
};
});
vi.mock('../../../src/schemas/household.schema.js', () => {
class MockHouseholdModel {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
mockSave();
return Promise.resolve(this);
}
toObject() {
return { _id: 'hh-new', ...this._data };
}
static findById = mockFindById;
static findOne = mockFindOne;
static findByIdAndUpdate = mockFindByIdAndUpdate;
}
return { HouseholdModel: MockHouseholdModel };
});
import { HouseholdsRepository } from '../../../src/modules/households/households.repository.js';
describe('HouseholdsRepository', () => {
let repo: HouseholdsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new HouseholdsRepository();
});
describe('findById', () => {
it('calls findById with lean', async () => {
const household = { _id: 'hh1', name: 'Test' };
mockExec.mockResolvedValue(household);
const result = await repo.findById('hh1');
expect(mockFindById).toHaveBeenCalledWith('hh1');
expect(result).toEqual(household);
});
});
describe('findByInviteCode', () => {
it('calls findOne with inviteCode', async () => {
const household = { _id: 'hh1', inviteCode: 'ABCD' };
mockExec.mockResolvedValue(household);
const result = await repo.findByInviteCode('ABCD');
expect(mockFindOne).toHaveBeenCalledWith({ inviteCode: 'ABCD' });
expect(result).toEqual(household);
});
});
describe('create', () => {
it('creates a household with owner as first member', async () => {
mockSave.mockResolvedValue({});
const result = await repo.create({ name: 'Test' }, 'owner-1', 'INVITE1');
expect(mockSave).toHaveBeenCalled();
expect(result).toMatchObject({ name: 'Test', ownerUserId: 'owner-1', inviteCode: 'INVITE1' });
});
});
describe('update', () => {
it('calls findByIdAndUpdate with $set', async () => {
mockExec.mockResolvedValue({ _id: 'hh1', name: 'Updated' });
const result = await repo.update('hh1', { name: 'Updated' });
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
'hh1',
{ $set: { name: 'Updated' } },
{ new: true, lean: true },
);
expect(result).toEqual({ _id: 'hh1', name: 'Updated' });
});
});
describe('addMember', () => {
it('pushes a new member to the array', async () => {
mockExec.mockResolvedValue({ _id: 'hh1', members: [] });
await repo.addMember('hh1', 'user-2', HouseholdRole.MEMBER);
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
'hh1',
{
$push: {
members: expect.objectContaining({
userId: 'user-2',
role: HouseholdRole.MEMBER,
}),
},
},
{ new: true, lean: true },
);
});
});
describe('updateInviteCode', () => {
it('sets the new invite code', async () => {
mockExec.mockResolvedValue({ _id: 'hh1', inviteCode: 'NEWCODE' });
const result = await repo.updateInviteCode('hh1', 'NEWCODE');
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
'hh1',
{ $set: { inviteCode: 'NEWCODE' } },
{ new: true, lean: true },
);
expect(result).toEqual({ _id: 'hh1', inviteCode: 'NEWCODE' });
});
});
});

View file

@ -0,0 +1,265 @@
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 { HouseholdRole } from '@meshitrack/shared';
// Mock jose for auth plugin
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: {},
}),
}));
// Hoist mock fns
const {
mockCreate,
mockFindById,
mockUpdate,
mockUpdateInviteCode,
mockFindByInviteCode,
mockAddMember,
mockFindByKeycloakId,
mockUserUpdate,
} = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockFindById: vi.fn(),
mockUpdate: vi.fn(),
mockUpdateInviteCode: vi.fn(),
mockFindByInviteCode: vi.fn(),
mockAddMember: vi.fn(),
mockFindByKeycloakId: vi.fn(),
mockUserUpdate: vi.fn(),
}));
vi.mock('../../../src/modules/households/households.repository.js', () => ({
HouseholdsRepository: class {
create = mockCreate;
findById = mockFindById;
update = mockUpdate;
updateInviteCode = mockUpdateInviteCode;
findByInviteCode = mockFindByInviteCode;
addMember = mockAddMember;
},
}));
vi.mock('../../../src/modules/users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = mockFindByKeycloakId;
update = mockUserUpdate;
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
vi.mock('uuid', () => ({
v4: vi.fn(() => '12345678-1234-1234-1234-123456789abc'),
}));
vi.mock('mongoose', () => ({
default: {
startSession: vi.fn().mockResolvedValue({
startTransaction: vi.fn(),
commitTransaction: vi.fn().mockResolvedValue(undefined),
abortTransaction: vi.fn().mockResolvedValue(undefined),
endSession: vi.fn(),
}),
},
}));
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 householdsRoutes from '../../../src/modules/households/households.routes.js';
function makeFakeHousehold(overrides = {}) {
return {
_id: 'hh1',
name: 'Test Household',
ownerUserId: 'kc-1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER, joinedAt: new Date().toISOString() }],
inviteCode: '12345678',
settings: { timezone: 'UTC', currency: 'USD', language: 'en' },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('households.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(householdsRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
// Default: auth plugin finds user with hh1 membership (routes with householdId guard pass)
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'], defaultHouseholdId: null });
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('POST /api/v1/households', () => {
it('creates a household', async () => {
const household = makeFakeHousehold();
mockCreate.mockResolvedValue(household);
mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null });
mockUserUpdate.mockResolvedValue({});
const res = await app.inject({
method: 'POST',
url: '/api/v1/households',
headers: authHeaders,
payload: { name: 'Test Household' },
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Test Household');
});
});
describe('POST /api/v1/households (ObjectId/Date conversion)', () => {
it('handles ObjectId and Date objects in response', async () => {
const household = makeFakeHousehold({
_id: { toString: () => 'hh-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
members: [
{
userId: 'kc-1',
role: HouseholdRole.OWNER,
joinedAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
},
],
settings: null,
});
mockCreate.mockResolvedValue(household);
mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null });
mockUserUpdate.mockResolvedValue({});
const res = await app.inject({
method: 'POST',
url: '/api/v1/households',
headers: authHeaders,
payload: { name: 'Test' },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body._id).toBe('hh-obj');
expect(body.createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.members[0].joinedAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.settings.timezone).toBe('UTC');
expect(body.settings.currency).toBe('USD');
expect(body.settings.language).toBe('en');
});
});
describe('GET /api/v1/households/:id', () => {
it('returns a household', async () => {
const household = makeFakeHousehold();
mockFindById.mockResolvedValue(household);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Test Household');
});
});
describe('PATCH /api/v1/households/:id', () => {
it('updates a household', async () => {
const household = makeFakeHousehold();
mockFindById.mockResolvedValue(household);
mockUpdate.mockResolvedValue({ ...household, name: 'Updated' });
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1',
headers: authHeaders,
payload: { name: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated');
});
});
describe('POST /api/v1/households/:id/invite', () => {
it('generates a new invite code', async () => {
const household = makeFakeHousehold();
mockFindById.mockResolvedValue(household);
mockUpdateInviteCode.mockResolvedValue({ ...household, inviteCode: '12345678' });
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/invite',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().inviteCode).toBeDefined();
});
});
describe('POST /api/v1/households/join', () => {
it('joins a household via invite code', async () => {
const household = makeFakeHousehold({
members: [
{ userId: 'kc-other', role: HouseholdRole.OWNER, joinedAt: new Date().toISOString() },
],
});
mockFindByInviteCode.mockResolvedValue(household);
mockAddMember.mockResolvedValue({
...household,
members: [
...household.members,
{ userId: 'kc-1', role: HouseholdRole.MEMBER, joinedAt: new Date().toISOString() },
],
});
mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null });
mockUserUpdate.mockResolvedValue({});
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/join',
headers: authHeaders,
payload: { inviteCode: '12345678' },
});
expect(res.statusCode).toBe(200);
});
});
});

View file

@ -0,0 +1,307 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HouseholdsService } from '../../../src/modules/households/households.service.js';
import { NotFoundError, ForbiddenError, ConflictError } from '../../../src/common/errors.js';
import { HouseholdRole } from '@meshitrack/shared';
// Mock uuid to return deterministic values
vi.mock('uuid', () => ({
v4: vi.fn(() => '12345678-1234-1234-1234-123456789abc'),
}));
const { mockSession } = vi.hoisted(() => ({
mockSession: {
startTransaction: vi.fn(),
commitTransaction: vi.fn().mockResolvedValue(undefined),
abortTransaction: vi.fn().mockResolvedValue(undefined),
endSession: vi.fn(),
},
}));
vi.mock('mongoose', () => ({
default: { startSession: vi.fn().mockResolvedValue(mockSession) },
}));
describe('HouseholdsService', () => {
const mockHouseholdsRepo = {
findById: vi.fn(),
findByInviteCode: vi.fn(),
create: vi.fn(),
update: vi.fn(),
addMember: vi.fn(),
updateInviteCode: vi.fn(),
};
const mockUsersRepo = {
findByKeycloakId: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsertFromToken: vi.fn(),
};
let service: HouseholdsService;
beforeEach(() => {
vi.clearAllMocks();
service = new HouseholdsService({
householdsRepository: mockHouseholdsRepo as never,
usersRepository: mockUsersRepo as never,
});
});
describe('create', () => {
it('creates a household and updates owner user', async () => {
const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1', inviteCode: '12345678' };
mockHouseholdsRepo.create.mockResolvedValue(household);
mockUsersRepo.findByKeycloakId.mockResolvedValue({
householdIds: [],
defaultHouseholdId: null,
});
mockUsersRepo.update.mockResolvedValue({});
const result = await service.create({ name: 'Test' }, 'kc-1');
expect(result).toEqual(household);
expect(mockHouseholdsRepo.create).toHaveBeenCalledWith(
{ name: 'Test' },
'kc-1',
'12345678',
mockSession,
);
expect(mockUsersRepo.update).toHaveBeenCalledWith(
'kc-1',
{ householdIds: ['hh1'], defaultHouseholdId: 'hh1' },
mockSession,
);
});
it('preserves existing defaultHouseholdId when user already has one', async () => {
const household = { _id: 'hh2', name: 'Second', ownerUserId: 'kc-1' };
mockHouseholdsRepo.create.mockResolvedValue(household);
mockUsersRepo.findByKeycloakId.mockResolvedValue({
householdIds: ['hh1'],
defaultHouseholdId: 'hh1',
});
mockUsersRepo.update.mockResolvedValue({});
await service.create({ name: 'Second' }, 'kc-1');
expect(mockUsersRepo.update).toHaveBeenCalledWith(
'kc-1',
{ householdIds: ['hh1', 'hh2'], defaultHouseholdId: 'hh1' },
mockSession,
);
});
it('aborts transaction on error', async () => {
mockHouseholdsRepo.create.mockRejectedValue(new Error('DB error'));
await expect(service.create({ name: 'Test' }, 'kc-1')).rejects.toThrow('DB error');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('handles case when owner user not found in db', async () => {
const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1' };
mockHouseholdsRepo.create.mockResolvedValue(household);
mockUsersRepo.findByKeycloakId.mockResolvedValue(null);
const result = await service.create({ name: 'Test' }, 'kc-1');
expect(result).toEqual(household);
expect(mockUsersRepo.update).not.toHaveBeenCalled();
});
});
describe('getById', () => {
it('returns household when found', async () => {
const household = { _id: 'hh1', name: 'Test' };
mockHouseholdsRepo.findById.mockResolvedValue(household);
const result = await service.getById('hh1');
expect(result).toEqual(household);
});
it('throws NotFoundError when not found', async () => {
mockHouseholdsRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('allows owner to update', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
mockHouseholdsRepo.update.mockResolvedValue({ _id: 'hh1', name: 'Updated' });
const result = await service.update('hh1', { name: 'Updated' }, 'kc-1');
expect(result).toEqual({ _id: 'hh1', name: 'Updated' });
});
it('allows admin to update', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-2', role: HouseholdRole.ADMIN }],
});
mockHouseholdsRepo.update.mockResolvedValue({ _id: 'hh1', name: 'Updated' });
await service.update('hh1', { name: 'Updated' }, 'kc-2');
expect(mockHouseholdsRepo.update).toHaveBeenCalled();
});
it('throws ForbiddenError for regular member', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-3', role: HouseholdRole.MEMBER }],
});
await expect(service.update('hh1', { name: 'X' }, 'kc-3')).rejects.toThrow(ForbiddenError);
});
it('throws NotFoundError when repo update returns null', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
mockHouseholdsRepo.update.mockResolvedValue(null);
await expect(service.update('hh1', { name: 'X' }, 'kc-1')).rejects.toThrow(NotFoundError);
});
it('throws ForbiddenError for non-member', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
await expect(service.update('hh1', { name: 'X' }, 'kc-other')).rejects.toThrow(
ForbiddenError,
);
});
});
describe('generateInviteCode', () => {
it('generates new invite code for owner', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
mockHouseholdsRepo.updateInviteCode.mockResolvedValue({ inviteCode: '12345678' });
const result = await service.generateInviteCode('hh1', 'kc-1');
expect(mockHouseholdsRepo.updateInviteCode).toHaveBeenCalledWith('hh1', '12345678');
expect(result).toEqual({ inviteCode: '12345678' });
});
it('throws ForbiddenError for regular member', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-3', role: HouseholdRole.MEMBER }],
});
await expect(service.generateInviteCode('hh1', 'kc-3')).rejects.toThrow(ForbiddenError);
});
it('throws NotFoundError when updateInviteCode returns null', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
mockHouseholdsRepo.updateInviteCode.mockResolvedValue(null);
await expect(service.generateInviteCode('hh1', 'kc-1')).rejects.toThrow(NotFoundError);
});
});
describe('join', () => {
it('joins a household via invite code', async () => {
const household = {
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
};
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
mockHouseholdsRepo.addMember.mockResolvedValue({
...household,
members: [...household.members, { userId: 'kc-2' }],
});
mockUsersRepo.findByKeycloakId.mockResolvedValue({
householdIds: [],
defaultHouseholdId: null,
});
mockUsersRepo.update.mockResolvedValue({});
const result = await service.join('ABCD1234', 'kc-2');
expect(mockHouseholdsRepo.addMember).toHaveBeenCalledWith(
'hh1',
'kc-2',
HouseholdRole.MEMBER,
mockSession,
);
expect(mockUsersRepo.update).toHaveBeenCalledWith(
'kc-2',
{ householdIds: ['hh1'], defaultHouseholdId: 'hh1' },
mockSession,
);
expect(result).toBeDefined();
});
it('throws NotFoundError for invalid invite code', async () => {
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(null);
await expect(service.join('INVALID', 'kc-2')).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when already a member', async () => {
mockHouseholdsRepo.findByInviteCode.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
await expect(service.join('CODE', 'kc-1')).rejects.toThrow(ConflictError);
});
it('throws NotFoundError when addMember returns null', async () => {
const household = {
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
};
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
mockHouseholdsRepo.addMember.mockResolvedValue(null);
await expect(service.join('CODE', 'kc-2')).rejects.toThrow(NotFoundError);
});
it('aborts transaction on error during join', async () => {
const household = {
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
};
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
mockHouseholdsRepo.addMember.mockRejectedValue(new Error('DB error'));
await expect(service.join('CODE', 'kc-2')).rejects.toThrow('DB error');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('handles case when joining user not found in db', async () => {
const household = {
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
};
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
mockHouseholdsRepo.addMember.mockResolvedValue({});
mockUsersRepo.findByKeycloakId.mockResolvedValue(null);
await service.join('CODE', 'kc-new');
expect(mockUsersRepo.update).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { NoOpLlmProvider } from '../../../src/modules/llm/no-op-llm.provider.js';
import { LLM_PROVIDER } from '../../../src/modules/llm/llm-provider.interface.js';
describe(NoOpLlmProvider.name, () => {
const provider = new NoOpLlmProvider();
it('exports LLM_PROVIDER symbol', () => {
expect(typeof LLM_PROVIDER).toBe('symbol');
});
it('extractNutrition returns null and warns', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await provider.extractNutrition({ text: 'apple' });
expect(result).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('extractNutrition'));
spy.mockRestore();
});
it('parseRecipe returns null and warns', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await provider.parseRecipe('pasta recipe');
expect(result).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseRecipe'));
spy.mockRestore();
});
it('parseRecipeFromUrl returns null and warns', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await provider.parseRecipeFromUrl('https://example.com');
expect(result).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseRecipeFromUrl'));
spy.mockRestore();
});
it('parseReceipt returns null and warns', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await provider.parseReceipt(Buffer.from('fake-image'));
expect(result).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseReceipt'));
spy.mockRestore();
});
it('suggestMealPlan returns null and warns', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await provider.suggestMealPlan({ householdId: 'hh1' });
expect(result).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('suggestMealPlan'));
spy.mockRestore();
});
it('parseNaturalLanguage returns null and warns', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await provider.parseNaturalLanguage('add milk');
expect(result).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseNaturalLanguage'));
spy.mockRestore();
});
});

View file

@ -0,0 +1,148 @@
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' }
});
});
});
});

View file

@ -0,0 +1,330 @@
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);
});
});
});

View file

@ -0,0 +1,261 @@
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);
});
});
});

View file

@ -0,0 +1,234 @@
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);
});
});
});

View file

@ -0,0 +1,272 @@
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);
});
});
});

View file

@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockAggregate, mockSave } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockAggregate: vi.fn(),
mockSave: vi.fn(),
}));
vi.mock('../../../src/schemas/medicine-price.schema.js', () => {
const chain = () => ({
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: mockFind,
});
const findOneChain = () => ({
sort: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: mockFindOne,
});
const aggregateChain = () => ({ exec: mockAggregate });
class FakeModel {
data: unknown;
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static aggregate = vi.fn(() => aggregateChain());
}
return { MedicinePriceModel: FakeModel };
});
import { MedicinePricesRepository } from '../../../src/modules/medicine-prices/medicine-prices.repository.js';
describe(MedicinePricesRepository.name, () => {
let repo: MedicinePricesRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new MedicinePricesRepository();
});
describe('create', () => {
it('saves and returns price record', async () => {
const data = {
householdId: 'hh1',
medicineProductId: 'mp-1',
medicineProductBrand: 'Tylenol',
medicineId: 'med-1',
medicineName: 'Acetaminophen',
storeId: 'st-1',
storeName: 'Walgreens',
price: 10,
currency: 'USD',
quantity: 100,
unit: 'tablet',
pricePerUnit: 0.1,
date: new Date(),
isInsurancePrice: false,
createdBy: 'user-1',
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data as never);
expect(mockSave).toHaveBeenCalled();
expect(result).toBeTruthy();
});
});
describe('findByMedicine', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'pr-1', medicineId: 'med-1' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
expect(result.pagination.cursor).toBeNull();
});
it('sets hasMore when more items exist', async () => {
const items = [{ _id: 'pr-1' }, { _id: 'pr-2' }, { _id: 'pr-3' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('handles cursor', async () => {
mockFind.mockResolvedValue([]);
const cursor = Buffer.from('pr-1').toString('base64');
const result = await repo.findByMedicine('hh1', 'med-1', { cursor, limit: 20 });
expect(result.pagination.hasMore).toBe(false);
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
});
it('applies storeId filter', async () => {
mockFind.mockResolvedValue([]);
await repo.findByMedicine('hh1', 'med-1', { storeId: 'st-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('applies startDate-only filter', async () => {
mockFind.mockResolvedValue([]);
await repo.findByMedicine('hh1', 'med-1', {
startDate: '2026-01-01T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('applies endDate-only filter', async () => {
mockFind.mockResolvedValue([]);
await repo.findByMedicine('hh1', 'med-1', {
endDate: '2026-12-31T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
});
describe('compareStores', () => {
it('returns store comparison results', async () => {
const rows = [
{
_id: 'st-1',
storeName: 'Walgreens',
latestPrice: 10,
latestPricePerUnit: 0.1,
currency: 'USD',
date: new Date(),
isInsurancePrice: false,
},
];
mockAggregate.mockResolvedValue(rows);
const result = await repo.compareStores('hh1', 'med-1');
expect(result).toHaveLength(1);
expect(result[0].storeId).toBe('st-1');
expect(result[0].storeName).toBe('Walgreens');
});
it('returns empty array when no records', async () => {
mockAggregate.mockResolvedValue([]);
const result = await repo.compareStores('hh1', 'med-1');
expect(result).toEqual([]);
});
});
describe('getLatestForMedicine', () => {
it('returns latest record', async () => {
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
mockFindOne.mockResolvedValue(record);
const result = await repo.getLatestForMedicine('hh1', 'med-1');
expect(result).toEqual(record);
});
it('filters by storeId when provided', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.getLatestForMedicine('hh1', 'med-1', 'st-1');
expect(result).toBeNull();
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
expect(await repo.getLatestForMedicine('hh1', 'med-1')).toBeNull();
});
});
describe('getAnalytics', () => {
it('returns analytics object with all fields', async () => {
mockAggregate.mockResolvedValue([]);
const result = await repo.getAnalytics('hh1', { period: 'month' });
expect(result).toHaveProperty('spendingOverTime');
expect(result).toHaveProperty('topBySpending');
expect(result).toHaveProperty('spendingByStore');
expect(result).toHaveProperty('priceAlerts');
});
it('uses quarter date format', async () => {
mockAggregate.mockResolvedValue([]);
const result = await repo.getAnalytics('hh1', { period: 'quarter' });
expect(result).toHaveProperty('spendingOverTime');
});
it('uses year date format', async () => {
mockAggregate.mockResolvedValue([]);
const result = await repo.getAnalytics('hh1', { period: 'year' });
expect(result).toHaveProperty('spendingOverTime');
});
it('handles non-empty analytics results', async () => {
mockAggregate
.mockResolvedValueOnce([{ period: '2026-01', total: 50 }])
.mockResolvedValueOnce([
{
medicineId: 'med-1',
medicineName: 'Acetaminophen',
totalSpent: 50,
avgPricePerUnit: 0.1,
},
])
.mockResolvedValueOnce([
{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 },
])
.mockResolvedValueOnce([]);
const result = await repo.getAnalytics('hh1', { period: 'month' });
expect(result.spendingOverTime).toHaveLength(1);
expect(result.topBySpending).toHaveLength(1);
expect(result.spendingByStore).toHaveLength(1);
expect(result.priceAlerts).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,340 @@
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 { mockRecordPrice, mockGetPriceHistory, mockCompareStores, mockGetAnalytics } = vi.hoisted(
() => ({
mockRecordPrice: vi.fn(),
mockGetPriceHistory: vi.fn(),
mockCompareStores: vi.fn(),
mockGetAnalytics: vi.fn(),
}),
);
vi.mock('../../../src/modules/medicine-prices/medicine-prices.repository.js', () => ({
MedicinePricesRepository: class {
create = vi.fn();
findByMedicine = vi.fn();
compareStores = vi.fn();
getLatestForMedicine = vi.fn();
getAnalytics = vi.fn();
},
}));
vi.mock('../../../src/modules/medicine-prices/medicine-prices.service.js', () => ({
MedicinePricesService: class {
recordPrice = mockRecordPrice;
getPriceHistory = mockGetPriceHistory;
compareStores = mockCompareStores;
getAnalytics = mockGetAnalytics;
},
}));
vi.mock('../../../src/modules/medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
findById = vi.fn();
},
}));
vi.mock('../../../src/modules/stores/stores.repository.js', () => ({
StoresRepository: class {
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 medicinePricesRoutes from '../../../src/modules/medicine-prices/medicine-prices.routes.js';
function makeFakePriceRecord(overrides = {}) {
return {
_id: 'pr-1',
householdId: 'hh1',
medicineProductId: 'mp-1',
medicineProductBrand: 'Tylenol',
medicineId: 'med-1',
medicineName: 'Acetaminophen',
storeId: 'st-1',
storeName: 'Walgreens',
price: 10,
currency: 'USD',
quantity: 100,
unit: 'tablet',
pricePerUnit: 0.1,
date: '2026-01-15T00:00:00.000Z',
isInsurancePrice: false,
createdBy: 'kc-1',
createdAt: '2026-01-15T00:00:00.000Z',
...overrides,
};
}
describe('medicine-prices.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(medicinePricesRoutes);
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('POST /api/v1/households/:householdId/medicine-prices', () => {
const validBody = {
medicineProductId: 'mp-1',
medicineId: 'med-1',
storeId: 'st-1',
price: 10,
currency: 'USD',
quantity: 100,
unit: 'tablet',
isInsurancePrice: false,
};
it('records price and returns 201', async () => {
mockRecordPrice.mockResolvedValue(makeFakePriceRecord());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicine-prices',
headers: authHeaders,
payload: validBody,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body._id).toBe('pr-1');
expect(body.pricePerUnit).toBe(0.1);
});
it('passes householdId and userId to service', async () => {
mockRecordPrice.mockResolvedValue(makeFakePriceRecord());
await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicine-prices',
headers: authHeaders,
payload: validBody,
});
expect(mockRecordPrice).toHaveBeenCalledWith(
expect.objectContaining({ medicineProductId: 'mp-1' }),
'hh1',
'kc-1',
);
});
it('includes notes in response when present', async () => {
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ notes: 'insurance price' }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicine-prices',
headers: authHeaders,
payload: validBody,
});
expect(res.statusCode).toBe(201);
expect(res.json().notes).toBe('insurance price');
});
it('returns 400 for missing required fields', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicine-prices',
headers: authHeaders,
payload: { price: 10 },
});
expect(res.statusCode).toBe(400);
});
it('handles Date objects in response', async () => {
mockRecordPrice.mockResolvedValue(
makeFakePriceRecord({
_id: { toString: () => 'pr-obj' },
date: new Date('2026-01-15T00:00:00.000Z'),
createdAt: new Date('2026-01-15T00:00:00.000Z'),
}),
);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicine-prices',
headers: authHeaders,
payload: validBody,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body._id).toBe('pr-obj');
expect(body.date).toBe('2026-01-15T00:00:00.000Z');
});
});
describe('GET /api/v1/households/:householdId/medicine-prices/history/:medicineId', () => {
it('returns paginated price history', async () => {
mockGetPriceHistory.mockResolvedValue({
data: [makeFakePriceRecord()],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-prices/history/med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.pagination.hasMore).toBe(false);
});
it('passes query params to service', async () => {
mockGetPriceHistory.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-prices/history/med-1?storeId=st-1&limit=10',
headers: authHeaders,
});
expect(mockGetPriceHistory).toHaveBeenCalledWith(
'hh1',
'med-1',
expect.objectContaining({ storeId: 'st-1', limit: 10 }),
);
});
});
describe('GET /api/v1/households/:householdId/medicine-prices/compare/:medicineId', () => {
it('returns store comparison', async () => {
mockCompareStores.mockResolvedValue([
{
storeId: 'st-1',
storeName: 'Walgreens',
latestPrice: 10,
latestPricePerUnit: 0.1,
currency: 'USD',
date: new Date('2026-01-15T00:00:00.000Z'),
isInsurancePrice: false,
},
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-prices/compare/med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].storeId).toBe('st-1');
expect(body.data[0].date).toBe('2026-01-15T00:00:00.000Z');
});
it('passes householdId and medicineId to service', async () => {
mockCompareStores.mockResolvedValue([]);
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-prices/compare/med-99',
headers: authHeaders,
});
expect(mockCompareStores).toHaveBeenCalledWith('hh1', 'med-99');
});
});
describe('GET /api/v1/households/:householdId/medicine-prices/analytics', () => {
it('returns analytics', async () => {
mockGetAnalytics.mockResolvedValue({
spendingOverTime: [{ period: '2026-01', total: 50 }],
topBySpending: [],
spendingByStore: [],
priceAlerts: [],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-prices/analytics',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.spendingOverTime).toHaveLength(1);
expect(body.spendingOverTime[0].total).toBe(50);
});
it('passes period query param to service', async () => {
mockGetAnalytics.mockResolvedValue({
spendingOverTime: [],
topBySpending: [],
spendingByStore: [],
priceAlerts: [],
});
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-prices/analytics?period=year',
headers: authHeaders,
});
expect(mockGetAnalytics).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ period: 'year' }),
);
});
});
});

View file

@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicinePricesService } from '../../../src/modules/medicine-prices/medicine-prices.service.js';
describe(MedicinePricesService.name, () => {
const mockPricesRepo = {
create: vi.fn(),
findByMedicine: vi.fn(),
compareStores: vi.fn(),
getLatestForMedicine: vi.fn(),
getAnalytics: vi.fn(),
};
const mockProductsRepo = {
findById: vi.fn(),
};
const mockStoresRepo = {
findById: vi.fn(),
};
let service: MedicinePricesService;
beforeEach(() => {
vi.clearAllMocks();
service = new MedicinePricesService({
medicinePricesRepository: mockPricesRepo as never,
medicineProductsRepository: mockProductsRepo as never,
storesRepository: mockStoresRepo as never,
});
});
describe('recordPrice', () => {
const validInput = {
medicineProductId: 'mp-1',
medicineId: 'med-1',
storeId: 'st-1',
price: 10,
currency: 'USD',
quantity: 100,
unit: 'tablet' as never,
isInsurancePrice: false,
};
it('creates price record with computed pricePerUnit', async () => {
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
mockPricesRepo.create.mockResolvedValue(record);
const result = await service.recordPrice(validInput, 'hh1', 'user-1');
expect(result).toEqual(record);
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
pricePerUnit: 0.1,
medicineName: 'Acetaminophen',
storeName: 'Walgreens',
}),
);
});
it('uses medicineName as brand fallback when brand is not set', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Generic', brand: undefined });
mockStoresRepo.findById.mockResolvedValue({ name: 'CVS' });
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
await service.recordPrice(validInput, 'hh1', 'user-1');
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ medicineProductBrand: 'Generic' }),
);
});
it('uses provided date when given', async () => {
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
await service.recordPrice(
{ ...validInput, date: '2026-01-15T00:00:00.000Z' },
'hh1',
'user-1',
);
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }),
);
});
it('throws NotFoundError when product not found', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
'Medicine product not found',
);
});
it('throws NotFoundError when store not found', async () => {
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue(null);
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
'Store not found',
);
});
});
describe('getPriceHistory', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockPricesRepo.findByMedicine.mockResolvedValue(result);
const response = await service.getPriceHistory('hh1', 'med-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockPricesRepo.findByMedicine).toHaveBeenCalledWith('hh1', 'med-1', { limit: 20 });
});
});
describe('compareStores', () => {
it('delegates to repository', async () => {
const comparisons = [{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 10 }];
mockPricesRepo.compareStores.mockResolvedValue(comparisons);
const result = await service.compareStores('hh1', 'med-1');
expect(result).toEqual(comparisons);
expect(mockPricesRepo.compareStores).toHaveBeenCalledWith('hh1', 'med-1');
});
});
describe('estimatePrice', () => {
it('returns pricePerUnit of latest record', async () => {
mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.15 });
const result = await service.estimatePrice('hh1', 'med-1');
expect(result).toBe(0.15);
});
it('returns null when no records', async () => {
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
const result = await service.estimatePrice('hh1', 'med-1');
expect(result).toBeNull();
});
it('filters by storeId when provided', async () => {
mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.2 });
await service.estimatePrice('hh1', 'med-1', 'st-1');
expect(mockPricesRepo.getLatestForMedicine).toHaveBeenCalledWith('hh1', 'med-1', 'st-1');
});
});
describe('getAnalytics', () => {
it('delegates to repository', async () => {
const analytics = {
spendingOverTime: [],
topBySpending: [],
spendingByStore: [],
priceAlerts: [],
};
mockPricesRepo.getAnalytics.mockResolvedValue(analytics);
const result = await service.getAnalytics('hh1', { period: 'month' });
expect(result).toEqual(analytics);
expect(mockPricesRepo.getAnalytics).toHaveBeenCalledWith('hh1', { period: 'month' });
});
});
});

View file

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DosageUnit, MedicineProductSource } from '@meshitrack/shared';
const {
mockExec,
_mockLean,
mockFind,
mockFindOne,
mockFindOneAndUpdate,
mockSave,
_mockSort,
_mockLimit,
mockCountDocuments,
} = vi.hoisted(() => {
const mockExec = vi.fn();
const mockLean = vi.fn(() => ({ exec: mockExec }));
const mockLimit = vi.fn(() => ({ lean: mockLean }));
const mockSort = vi.fn(() => ({ limit: mockLimit }));
const mockCountDocuments = vi.fn();
return {
mockExec,
mockLean,
mockFind: vi.fn(() => ({ sort: mockSort })),
mockFindOne: vi.fn(() => ({ lean: mockLean })),
mockFindOneAndUpdate: vi.fn(() => ({ exec: mockExec })),
mockSave: vi.fn(),
mockSort,
mockLimit,
mockCountDocuments,
};
});
vi.mock('../../../src/schemas/medicine-product.schema.js', () => {
class MockMedicineProductModel {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
mockSave();
return Promise.resolve(this);
}
toObject() {
return { _id: 'mp-new', ...this._data };
}
static find = mockFind;
static findOne = mockFindOne;
static findOneAndUpdate = mockFindOneAndUpdate;
static countDocuments = vi.fn(() => ({ exec: mockCountDocuments }));
}
return { MedicineProductModel: MockMedicineProductModel };
});
import { MedicineProductsRepository } from '../../../src/modules/medicine-products/medicine-products.repository.js';
describe(MedicineProductsRepository.name, () => {
let repo: MedicineProductsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new MedicineProductsRepository();
});
describe('findByMedicine', () => {
it('returns paginated results', async () => {
const items = [
{ _id: 'mp-1', brand: 'CVS' },
{ _id: 'mp-2', brand: 'Kirkland' },
];
mockExec.mockResolvedValue(items);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
expect(mockFind).toHaveBeenCalledWith({
householdId: 'hh1',
medicineId: 'med-1',
isDeleted: false,
});
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(false);
});
it('detects hasMore when extra item returned', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `mp-${i}`, brand: `Brand ${i}` }));
mockExec.mockResolvedValue(items);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('decodes cursor for pagination', async () => {
mockExec.mockResolvedValue([]);
const cursor = Buffer.from('mp-5').toString('base64');
await repo.findByMedicine('hh1', 'med-1', { cursor, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(expect.objectContaining({ _id: { $gt: 'mp-5' } }));
});
it('returns null cursor when no data', async () => {
mockExec.mockResolvedValue([]);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('findById', () => {
it('finds by id and householdId', async () => {
const product = { _id: 'mp-1', brand: 'CVS' };
mockExec.mockResolvedValue(product);
const result = await repo.findById('mp-1', 'hh1');
expect(mockFindOne).toHaveBeenCalledWith({
_id: 'mp-1',
householdId: 'hh1',
isDeleted: false,
});
expect(result).toEqual(product);
});
});
describe('create', () => {
it('creates a medicine product', async () => {
mockSave.mockResolvedValue({});
const data = {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
source: MedicineProductSource.MANUAL,
};
const result = await repo.create(data, 'hh1', 'med-1', 'Metformin', 'kc-1');
expect(mockSave).toHaveBeenCalled();
expect(result).toMatchObject({
brand: 'CVS Health',
householdId: 'hh1',
medicineId: 'med-1',
medicineName: 'Metformin',
createdBy: 'kc-1',
});
});
});
describe('update', () => {
it('updates a medicine product', async () => {
mockExec.mockResolvedValue({ _id: 'mp-1', brand: 'Updated' });
const result = await repo.update('mp-1', 'hh1', { brand: 'Updated' });
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'mp-1', householdId: 'hh1', isDeleted: false },
{ $set: { brand: 'Updated' } },
{ new: true, lean: true },
);
expect(result).toEqual({ _id: 'mp-1', brand: 'Updated' });
});
});
describe('softDelete', () => {
it('sets isDeleted to true', async () => {
mockExec.mockResolvedValue({ _id: 'mp-1', isDeleted: true });
await repo.softDelete('mp-1', 'hh1');
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'mp-1', householdId: 'hh1', isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
);
});
});
describe('countByMedicineId', () => {
it('returns count of non-deleted products for medicine', async () => {
mockCountDocuments.mockResolvedValue(3);
const result = await repo.countByMedicineId('med-1');
expect(result).toBe(3);
});
});
});

View file

@ -0,0 +1,250 @@
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 { DosageUnit, MedicineProductSource } 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 {
mockFindByMedicine,
mockFindById,
mockCreate,
mockUpdate,
mockSoftDelete,
mockMedicineFindById,
} = vi.hoisted(() => ({
mockFindByMedicine: vi.fn(),
mockFindById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
mockMedicineFindById: vi.fn(),
}));
vi.mock('../../../src/modules/medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
findByMedicine = mockFindByMedicine;
findById = mockFindById;
create = mockCreate;
update = mockUpdate;
softDelete = mockSoftDelete;
},
}));
vi.mock('../../../src/modules/medicines/medicines.repository.js', () => ({
MedicinesRepository: class {
findById = mockMedicineFindById;
},
}));
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 medicinesRoutes from '../../../src/modules/medicines/medicines.routes.js';
import medicineProductsRoutes from '../../../src/modules/medicine-products/medicine-products.routes.js';
function makeFakeProduct(overrides = {}) {
return {
_id: 'mp-1',
householdId: 'hh1',
medicineId: 'med-1',
medicineName: 'Metformin',
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
source: MedicineProductSource.MANUAL,
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('medicine-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(medicinesRoutes);
await instance.register(medicineProductsRoutes);
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/medicines/:medicineId/products', () => {
it('returns paginated list', async () => {
const product = makeFakeProduct();
mockFindByMedicine.mockResolvedValue({
data: [product],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines/med-1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].brand).toBe('CVS Health');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const product = makeFakeProduct({
_id: { toString: () => 'mp-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
manufacturer: 'Pfizer',
imageUrl: 'https://example.com/img.png',
notes: 'Store in cool place',
});
mockFindByMedicine.mockResolvedValue({
data: [product],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines/med-1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('mp-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].manufacturer).toBe('Pfizer');
expect(body.data[0].imageUrl).toBe('https://example.com/img.png');
expect(body.data[0].notes).toBe('Store in cool place');
});
});
describe('GET /api/v1/households/:householdId/medicine-products/:id', () => {
it('returns a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().brand).toBe('CVS Health');
});
it('includes concentration fields in response when present', async () => {
mockFindById.mockResolvedValue(
makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }),
);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().concentration).toBe(5);
expect(res.json().concentrationUnit).toBe('mg/ml');
});
});
describe('POST /api/v1/households/:householdId/medicines/:medicineId/products', () => {
it('creates a product', async () => {
mockMedicineFindById.mockResolvedValue({ _id: 'med-1', name: 'Metformin' });
mockCreate.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicines/med-1/products',
headers: authHeaders,
payload: {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
},
});
expect(res.statusCode).toBe(201);
expect(res.json().brand).toBe('CVS Health');
});
});
describe('PATCH /api/v1/households/:householdId/medicine-products/:id', () => {
it('updates a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
mockUpdate.mockResolvedValue(makeFakeProduct({ brand: 'Updated' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
payload: { brand: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().brand).toBe('Updated');
});
});
describe('DELETE /api/v1/households/:householdId/medicine-products/:id', () => {
it('soft deletes a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
mockSoftDelete.mockResolvedValue(makeFakeProduct({ isDeleted: true }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
});

View file

@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicineProductsService } from '../../../src/modules/medicine-products/medicine-products.service.js';
import { NotFoundError } from '../../../src/common/errors.js';
import { DosageUnit, MedicineProductSource } from '@meshitrack/shared';
describe(MedicineProductsService.name, () => {
const mockProductsRepo = {
findByMedicine: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockMedicinesRepo = {
findById: vi.fn(),
};
let service: MedicineProductsService;
beforeEach(() => {
vi.clearAllMocks();
service = new MedicineProductsService({
medicineProductsRepository: mockProductsRepo as never,
medicinesRepository: mockMedicinesRepo as never,
});
});
describe('listByMedicine', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockProductsRepo.findByMedicine.mockResolvedValue(expected);
const result = await service.listByMedicine('hh1', 'med-1', { limit: 20 });
expect(mockProductsRepo.findByMedicine).toHaveBeenCalledWith('hh1', 'med-1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getById', () => {
it('returns product when found', async () => {
const product = { _id: 'mp-1', brand: 'CVS' };
mockProductsRepo.findById.mockResolvedValue(product);
const result = await service.getById('mp-1', 'hh1');
expect(result).toEqual(product);
});
it('throws NotFoundError when not found', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const data = {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
source: MedicineProductSource.MANUAL,
};
it('creates when parent medicine exists', async () => {
mockMedicinesRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Metformin' });
mockProductsRepo.create.mockResolvedValue({
_id: 'mp-1',
...data,
medicineName: 'Metformin',
});
const result = await service.create(data, 'hh1', 'med-1', 'kc-1');
expect(mockMedicinesRepo.findById).toHaveBeenCalledWith('med-1', 'hh1');
expect(mockProductsRepo.create).toHaveBeenCalledWith(
data,
'hh1',
'med-1',
'Metformin',
'kc-1',
);
expect(result._id).toBe('mp-1');
});
it('throws NotFoundError when parent medicine does not exist', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.create(data, 'hh1', 'missing', 'kc-1')).rejects.toThrow(NotFoundError);
expect(mockProductsRepo.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('updates a product', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1', brand: 'CVS' });
mockProductsRepo.update.mockResolvedValue({ _id: 'mp-1', brand: 'Updated' });
const result = await service.update('mp-1', 'hh1', { brand: 'Updated' });
expect(result.brand).toBe('Updated');
});
it('throws NotFoundError when product does not exist', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.update('missing', 'hh1', { brand: 'X' })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when update returns null', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1' });
mockProductsRepo.update.mockResolvedValue(null);
await expect(service.update('mp-1', 'hh1', { brand: 'X' })).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('soft deletes a product', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1' });
mockProductsRepo.softDelete.mockResolvedValue({ _id: 'mp-1', isDeleted: true });
await service.delete('mp-1', 'hh1');
expect(mockProductsRepo.softDelete).toHaveBeenCalledWith('mp-1', 'hh1');
});
it('throws NotFoundError when not found', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1' });
mockProductsRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('mp-1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -0,0 +1,215 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
const {
mockExec,
_mockLean,
mockFind,
mockFindOne,
mockFindOneAndUpdate,
mockSave,
_mockSort,
_mockLimit,
} = vi.hoisted(() => {
const mockExec = vi.fn();
const mockLean = vi.fn(() => ({ exec: mockExec }));
const mockLimit = vi.fn(() => ({ lean: mockLean }));
const mockSort = vi.fn(() => ({ limit: mockLimit }));
return {
mockExec,
mockLean,
mockFind: vi.fn(() => ({ sort: mockSort })),
mockFindOne: vi.fn(() => ({ lean: mockLean })),
mockFindOneAndUpdate: vi.fn(() => ({ exec: mockExec })),
mockSave: vi.fn(),
mockSort,
mockLimit,
};
});
vi.mock('../../../src/schemas/medicine.schema.js', () => {
class MockMedicineModel {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
mockSave();
return Promise.resolve(this);
}
toObject() {
return { _id: 'med-new', ...this._data };
}
static find = mockFind;
static findOne = mockFindOne;
static findOneAndUpdate = mockFindOneAndUpdate;
}
return { MedicineModel: MockMedicineModel };
});
import { MedicinesRepository } from '../../../src/modules/medicines/medicines.repository.js';
describe(MedicinesRepository.name, () => {
let repo: MedicinesRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new MedicinesRepository();
});
describe('findByHousehold', () => {
it('returns paginated results', async () => {
const items = [
{ _id: 'med-1', name: 'Aspirin' },
{ _id: 'med-2', name: 'Ibuprofen' },
];
mockExec.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(mockFind).toHaveBeenCalledWith({ householdId: 'hh1', isDeleted: false });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(false);
});
it('applies partial name search when q is provided', async () => {
mockExec.mockResolvedValue([]);
await repo.findByHousehold('hh1', { q: 'asp', limit: 20 });
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({ name: { $regex: 'asp', $options: 'i' } }),
);
});
it('applies category filter', async () => {
mockExec.mockResolvedValue([]);
await repo.findByHousehold('hh1', { category: MedicineCategory.PRESCRIPTION, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({ category: MedicineCategory.PRESCRIPTION }),
);
});
it('applies form filter', async () => {
mockExec.mockResolvedValue([]);
await repo.findByHousehold('hh1', { form: MedicineForm.TABLET, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(expect.objectContaining({ form: MedicineForm.TABLET }));
});
it('detects hasMore when extra item returned', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `med-${i}`, name: `Med ${i}` }));
mockExec.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('decodes cursor for pagination', async () => {
mockExec.mockResolvedValue([]);
const cursor = Buffer.from('med-5').toString('base64');
await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(expect.objectContaining({ _id: { $gt: 'med-5' } }));
});
});
describe('findById', () => {
it('finds by id and householdId', async () => {
const medicine = { _id: 'med-1', name: 'Aspirin' };
mockExec.mockResolvedValue(medicine);
const result = await repo.findById('med-1', 'hh1');
expect(mockFindOne).toHaveBeenCalledWith({
_id: 'med-1',
householdId: 'hh1',
isDeleted: false,
});
expect(result).toEqual(medicine);
});
});
describe('findDuplicate', () => {
it('finds medicine with matching fields', async () => {
mockExec.mockResolvedValue({ _id: 'med-1' });
const result = await repo.findDuplicate('hh1', 'Aspirin', 500, 'mg', 'tablet');
expect(mockFindOne).toHaveBeenCalledWith({
householdId: 'hh1',
name: 'Aspirin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
isDeleted: false,
});
expect(result).toBeTruthy();
});
it('excludes specified id', async () => {
mockExec.mockResolvedValue(null);
await repo.findDuplicate('hh1', 'Aspirin', 500, 'mg', 'tablet', 'med-1');
expect(mockFindOne).toHaveBeenCalledWith(expect.objectContaining({ _id: { $ne: 'med-1' } }));
});
});
describe('create', () => {
it('creates a medicine', async () => {
mockSave.mockResolvedValue({});
const data = {
name: 'Aspirin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.OTC,
tags: [],
};
const result = await repo.create(data, 'hh1', 'kc-1');
expect(mockSave).toHaveBeenCalled();
expect(result).toMatchObject({ name: 'Aspirin', householdId: 'hh1', createdBy: 'kc-1' });
});
});
describe('update', () => {
it('updates a medicine', async () => {
mockExec.mockResolvedValue({ _id: 'med-1', name: 'Updated' });
const result = await repo.update('med-1', 'hh1', { name: 'Updated' });
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'med-1', householdId: 'hh1', isDeleted: false },
{ $set: { name: 'Updated' } },
{ new: true, lean: true },
);
expect(result).toEqual({ _id: 'med-1', name: 'Updated' });
});
});
describe('softDelete', () => {
it('sets isDeleted to true', async () => {
mockExec.mockResolvedValue({ _id: 'med-1', isDeleted: true });
await repo.softDelete('med-1', 'hh1');
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'med-1', householdId: 'hh1', isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
);
});
});
});

View file

@ -0,0 +1,247 @@
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 { MedicineForm, StrengthUnit, MedicineCategory } 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,
mockFindDuplicate,
mockCreate,
mockUpdate,
mockSoftDelete,
mockCountByMedicineId,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockFindDuplicate: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
mockCountByMedicineId: vi.fn(),
}));
vi.mock('../../../src/modules/medicines/medicines.repository.js', () => ({
MedicinesRepository: class {
findByHousehold = mockFindByHousehold;
findById = mockFindById;
findDuplicate = mockFindDuplicate;
create = mockCreate;
update = mockUpdate;
softDelete = mockSoftDelete;
},
}));
vi.mock('../../../src/modules/medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
countByMedicineId = mockCountByMedicineId;
},
}));
vi.mock('../../../src/modules/medicine-products/medicine-products.service.js', () => ({
MedicineProductsService: class {
listByMedicine = vi.fn();
getById = 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 medicinesRoutes from '../../../src/modules/medicines/medicines.routes.js';
import medicineProductsRoutes from '../../../src/modules/medicine-products/medicine-products.routes.js';
function makeFakeMedicine(overrides = {}) {
return {
_id: 'med-1',
householdId: 'hh1',
name: 'Metformin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.PRESCRIPTION,
tags: [],
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('medicines.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(medicineProductsRoutes);
await instance.register(medicinesRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
mockCountByMedicineId.mockResolvedValue(0);
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('GET /api/v1/households/:householdId/medicines', () => {
it('returns paginated list', async () => {
const medicine = makeFakeMedicine();
mockFindByHousehold.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe('Metformin');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const medicine = makeFakeMedicine({
_id: { toString: () => 'med-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
notes: 'Take with food',
});
mockFindByHousehold.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('med-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].notes).toBe('Take with food');
});
});
describe('GET /api/v1/households/:householdId/medicines/:id', () => {
it('returns a medicine', async () => {
mockFindById.mockResolvedValue(makeFakeMedicine());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines/med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Metformin');
});
});
describe('POST /api/v1/households/:householdId/medicines', () => {
it('creates a medicine', async () => {
mockFindDuplicate.mockResolvedValue(null);
mockCreate.mockResolvedValue(makeFakeMedicine());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicines',
headers: authHeaders,
payload: {
name: 'Metformin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.PRESCRIPTION,
},
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Metformin');
});
});
describe('PATCH /api/v1/households/:householdId/medicines/:id', () => {
it('updates a medicine', async () => {
mockFindById.mockResolvedValue(makeFakeMedicine());
mockFindDuplicate.mockResolvedValue(null);
mockUpdate.mockResolvedValue(makeFakeMedicine({ name: 'Updated' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/medicines/med-1',
headers: authHeaders,
payload: { name: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated');
});
});
describe('DELETE /api/v1/households/:householdId/medicines/:id', () => {
it('soft deletes a medicine', async () => {
mockFindById.mockResolvedValue(makeFakeMedicine());
mockSoftDelete.mockResolvedValue(makeFakeMedicine({ isDeleted: true }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/medicines/med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
});

View file

@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicinesService } from '../../../src/modules/medicines/medicines.service.js';
import { NotFoundError, ConflictError } from '../../../src/common/errors.js';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
describe(MedicinesService.name, () => {
const mockRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockProductsRepo = {
countByMedicineId: vi.fn(),
};
let service: MedicinesService;
beforeEach(() => {
vi.clearAllMocks();
service = new MedicinesService({
medicinesRepository: mockRepo as never,
medicineProductsRepository: mockProductsRepo 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 medicine when found', async () => {
const medicine = { _id: 'med-1', name: 'Aspirin' };
mockRepo.findById.mockResolvedValue(medicine);
const result = await service.getById('med-1', 'hh1');
expect(result).toEqual(medicine);
});
it('throws NotFoundError when not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const data = {
name: 'Aspirin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.OTC,
tags: [],
};
it('creates when no duplicate exists', async () => {
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'med-1', ...data });
const result = await service.create(data, 'hh1', 'kc-1');
expect(mockRepo.findDuplicate).toHaveBeenCalledWith(
'hh1',
'Aspirin',
500,
StrengthUnit.MG,
MedicineForm.TABLET,
);
expect(result._id).toBe('med-1');
});
it('throws ConflictError when duplicate exists', async () => {
mockRepo.findDuplicate.mockResolvedValue({ _id: 'existing' });
await expect(service.create(data, 'hh1', 'kc-1')).rejects.toThrow(ConflictError);
expect(mockRepo.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('updates a medicine', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Aspirin',
strength: 500,
strengthUnit: StrengthUnit.MG,
form: MedicineForm.TABLET,
});
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue({ _id: 'med-1', name: 'Updated' });
const result = await service.update('med-1', 'hh1', { name: 'Updated' });
expect(result.name).toBe('Updated');
});
it('throws NotFoundError when medicine does not exist', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.update('missing', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when update would create duplicate', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Aspirin',
strength: 500,
strengthUnit: StrengthUnit.MG,
form: MedicineForm.TABLET,
});
mockRepo.findDuplicate.mockResolvedValue({ _id: 'med-2' });
await expect(service.update('med-1', 'hh1', { name: 'Ibuprofen' })).rejects.toThrow(
ConflictError,
);
});
it('throws NotFoundError when repo update returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
mockRepo.update.mockResolvedValue(null);
await expect(service.update('med-1', 'hh1', { notes: 'Updated' })).rejects.toThrow(
NotFoundError,
);
});
it('uses current name when name not provided in update', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Aspirin',
strength: 500,
strengthUnit: StrengthUnit.MG,
form: MedicineForm.TABLET,
});
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue({ _id: 'med-1', name: 'Aspirin', strength: 250 });
const result = await service.update('med-1', 'hh1', { strength: 250 });
expect(result).toBeTruthy();
expect(mockRepo.findDuplicate).toHaveBeenCalledWith(
'hh1',
'Aspirin',
250,
StrengthUnit.MG,
MedicineForm.TABLET,
'med-1',
);
});
it('skips dedup check when no identity fields change', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' });
await service.update('med-1', 'hh1', { notes: 'Updated notes' });
expect(mockRepo.findDuplicate).not.toHaveBeenCalled();
});
});
describe('delete', () => {
it('soft deletes a medicine', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1' });
mockProductsRepo.countByMedicineId.mockResolvedValue(0);
mockRepo.softDelete.mockResolvedValue({ _id: 'med-1', isDeleted: true });
await service.delete('med-1', 'hh1');
expect(mockProductsRepo.countByMedicineId).toHaveBeenCalledWith('med-1');
expect(mockRepo.softDelete).toHaveBeenCalledWith('med-1', 'hh1');
});
it('throws ConflictError when linked products exist', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1' });
mockProductsRepo.countByMedicineId.mockResolvedValue(3);
await expect(service.delete('med-1', 'hh1')).rejects.toThrow(ConflictError);
expect(mockRepo.softDelete).not.toHaveBeenCalled();
});
it('throws NotFoundError when not found', 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({ _id: 'med-1' });
mockProductsRepo.countByMedicineId.mockResolvedValue(0);
mockRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('med-1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NutritionTargetRepository } from '../../../src/modules/nutrition-targets/nutrition-target.repository.js';
const { mockSave, MockTargetModel } = 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(),
updateMany: vi.fn(),
});
return { mockSave, MockTargetModel: MockModel };
});
vi.mock('../../../src/schemas/nutrition-target.schema.js', () => ({
NutritionTargetModel: MockTargetModel,
}));
const { NutritionTargetModel } = await import('../../../src/schemas/nutrition-target.schema.js');
function makeChain(result: unknown = null) {
return {
sort: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(result),
};
}
describe(NutritionTargetRepository.name, () => {
let repo: NutritionTargetRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new NutritionTargetRepository();
});
describe('findByUser', () => {
it('queries by userId, householdId, and isActive: true', async () => {
const mockTarget = { _id: 't1', dailyCalories: 2000 };
vi.mocked(NutritionTargetModel.findOne).mockReturnValue(makeChain(mockTarget) as never);
const result = await repo.findByUser('user1', 'hh1');
expect(NutritionTargetModel.findOne).toHaveBeenCalledWith({
userId: 'user1',
householdId: 'hh1',
isActive: true,
});
expect(result).toEqual(mockTarget);
});
});
describe('findAllByUser', () => {
it('returns all targets sorted by newest first', async () => {
const chain = makeChain([]);
vi.mocked(NutritionTargetModel.find).mockReturnValue(chain as never);
await repo.findAllByUser('user1', 'hh1');
expect(NutritionTargetModel.find).toHaveBeenCalledWith({
userId: 'user1',
householdId: 'hh1',
});
expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 });
});
});
describe('create', () => {
it('saves and returns new document', async () => {
const plainDoc = { _id: 'new-id', dailyCalories: 2000 };
mockSave.mockResolvedValue({ toObject: () => plainDoc });
const result = await repo.create({ dailyCalories: 2000 });
expect(mockSave).toHaveBeenCalled();
expect(result).toEqual(plainDoc);
});
});
describe('deactivateAllForUser', () => {
it('updates all active targets for the user to inactive', async () => {
vi.mocked(NutritionTargetModel.updateMany).mockReturnValue({
exec: vi.fn().mockResolvedValue({ modifiedCount: 1 }),
} as never);
await repo.deactivateAllForUser('user1', 'hh1');
expect(NutritionTargetModel.updateMany).toHaveBeenCalledWith(
{ userId: 'user1', householdId: 'hh1', isActive: true },
{ $set: { isActive: false } },
);
});
});
describe('update', () => {
it('updates specific target using findOneAndUpdate', async () => {
const updatedDoc = { _id: 't1', dailyCalories: 2100 };
vi.mocked(NutritionTargetModel.findOneAndUpdate).mockReturnValue(makeChain(updatedDoc) as never);
const result = await repo.update('t1', 'user1', 'hh1', { dailyCalories: 2100 });
expect(NutritionTargetModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 't1', userId: 'user1', householdId: 'hh1' },
{ $set: { dailyCalories: 2100 } },
{ new: true, lean: true }
);
expect(result).toEqual(updatedDoc);
});
});
});

View file

@ -0,0 +1,201 @@
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 {
mockFindByUser,
mockFindAllByUser,
mockDeactivateAllForUser,
mockCreate,
} = vi.hoisted(() => ({
mockFindByUser: vi.fn(),
mockFindAllByUser: vi.fn(),
mockDeactivateAllForUser: vi.fn(),
mockCreate: vi.fn(),
}));
vi.mock('../../../src/modules/nutrition-targets/nutrition-target.repository.js', () => ({
NutritionTargetRepository: class {
findByUser = mockFindByUser;
findAllByUser = mockFindAllByUser;
deactivateAllForUser = mockDeactivateAllForUser;
create = mockCreate;
},
}));
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 nutritionTargetRoutes from '../../../src/modules/nutrition-targets/nutrition-target.routes.js';
function makeTarget(overrides = {}) {
return {
_id: 'target-1',
userId: 'kc-1',
householdId: 'hh1',
dailyCalories: 2000,
proteinG: 150,
carbsG: 200,
fatG: 67,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('nutrition-target.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(nutritionTargetRoutes);
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/nutrition-targets', () => {
it('returns the active target if found', async () => {
mockFindByUser.mockResolvedValue(makeTarget());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/nutrition-targets',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.dailyCalories).toBe(2000);
expect(body.isActive).toBe(true);
});
it('returns a message object if no target is set', async () => {
mockFindByUser.mockResolvedValue(null);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/nutrition-targets',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().message).toBe('No active targets defined');
});
});
describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => {
it('returns historical targets with optional fields and object _id', async () => {
mockFindAllByUser.mockResolvedValue([
makeTarget({
_id: { toString: () => 'target-1' },
isActive: false,
fiberG: 30,
sugarG: 50,
sodiumMg: 2000,
createdAt: new Date(),
}),
makeTarget()
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/nutrition-targets/history',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toHaveLength(2);
expect(body[0].fiberG).toBe(30);
expect(body[0]._id).toBe('target-1');
});
});
describe('POST /api/v1/households/:householdId/nutrition-targets', () => {
it('creates target and returns it', async () => {
mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id', createdAt: new Date(), updatedAt: new Date() }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/nutrition-targets',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
dailyCalories: 1800,
proteinG: 135,
carbsG: 180,
fatG: 60,
}),
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.dailyCalories).toBe(1800);
expect(body.isActive).toBe(true); // auto activated
});
});
describe('POST /api/v1/households/:householdId/nutrition-targets/presets', () => {
it('returns calculated splits', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/nutrition-targets/presets',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
calories: 2000,
strategy: 'loss',
}),
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Loss is 40% protein (200g), 30% carbs (150g), 30% fat (67g)
expect(body.dailyCalories).toBe(2000);
expect(body.proteinG).toBe(200);
});
});
});

View file

@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NutritionTargetService } from '../../../src/modules/nutrition-targets/nutrition-target.service.js';
import type { NutritionTargetRepository } from '../../../src/modules/nutrition-targets/nutrition-target.repository.js';
describe(NutritionTargetService.name, () => {
let service: NutritionTargetService;
let mockRepo: { [K in keyof NutritionTargetRepository]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
findByUser: vi.fn(),
findAllByUser: vi.fn(),
create: vi.fn(),
deactivateAllForUser: vi.fn(),
update: vi.fn(),
} as never;
service = new NutritionTargetService({
nutritionTargetRepository: mockRepo as unknown as NutritionTargetRepository,
});
});
describe('getActiveByUser', () => {
it('delegates to repository', async () => {
const mockTarget = { dailyCalories: 2000, isActive: true };
mockRepo.findByUser.mockResolvedValue(mockTarget);
const result = await service.getActiveByUser('u1', 'h1');
expect(mockRepo.findByUser).toHaveBeenCalledWith('u1', 'h1');
expect(result).toEqual(mockTarget);
});
});
describe('getAllByUser', () => {
it('delegates to repository', async () => {
const mockTargets = [{ dailyCalories: 2000 }, { dailyCalories: 1800 }];
mockRepo.findAllByUser.mockResolvedValue(mockTargets);
const result = await service.getAllByUser('u1', 'h1');
expect(mockRepo.findAllByUser).toHaveBeenCalledWith('u1', 'h1');
expect(result).toEqual(mockTargets);
});
});
describe('setTarget', () => {
it('deactivates existing targets before creating an active one', async () => {
const mockInput = { dailyCalories: 2000, proteinG: 100, carbsG: 200, fatG: 50, isActive: true };
const createdTarget = { ...mockInput, _id: 'new-id', userId: 'u1', householdId: 'h1' };
mockRepo.create.mockResolvedValue(createdTarget);
const result = await service.setTarget('u1', 'h1', mockInput);
expect(mockRepo.deactivateAllForUser).toHaveBeenCalledWith('u1', 'h1');
expect(mockRepo.create).toHaveBeenCalledWith({
...mockInput,
userId: 'u1',
householdId: 'h1',
});
expect(result).toEqual(createdTarget);
});
it('does NOT deactivate others if isActive is explicitly false', async () => {
const mockInput = { dailyCalories: 2000, proteinG: 100, carbsG: 200, fatG: 50, isActive: false };
await service.setTarget('u1', 'h1', mockInput);
expect(mockRepo.deactivateAllForUser).not.toHaveBeenCalled();
expect(mockRepo.create).toHaveBeenCalled();
});
});
describe('calculatePreset', () => {
it('calculates macros correctly for maintenance (30p / 40c / 30f)', () => {
const result = service.calculatePreset(2000, 'maintenance');
// Math:
// Protein: (2000 * 0.3) / 4 = 600 / 4 = 150
// Carbs: (2000 * 0.4) / 4 = 800 / 4 = 200
// Fat: (2000 * 0.3) / 9 = 600 / 9 = 66.66 => 67
expect(result).toEqual({
dailyCalories: 2000,
proteinG: 150,
carbsG: 200,
fatG: 67,
isActive: true,
});
});
it('calculates macros correctly for loss (40p / 30c / 30f)', () => {
const result = service.calculatePreset(2000, 'loss');
// Math:
// Protein: (2000 * 0.4) / 4 = 800 / 4 = 200
// Carbs: (2000 * 0.3) / 4 = 600 / 4 = 150
// Fat: (2000 * 0.3) / 9 = 600 / 9 = 66.66 => 67
expect(result).toEqual({
dailyCalories: 2000,
proteinG: 200,
carbsG: 150,
fatG: 67,
isActive: true,
});
});
it('calculates macros correctly for gain (25p / 50c / 25f)', () => {
const result = service.calculatePreset(2000, 'gain');
// Math:
// Protein: (2000 * 0.25) / 4 = 500 / 4 = 125
// Carbs: (2000 * 0.5) / 4 = 1000 / 4 = 250
// Fat: (2000 * 0.25) / 9 = 500 / 9 = 55.55 => 56
expect(result).toEqual({
dailyCalories: 2000,
proteinG: 125,
carbsG: 250,
fatG: 56,
isActive: true,
});
});
});
});

View file

@ -0,0 +1,186 @@
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/organizer-fill.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;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
}
return { OrganizerFillModel: FakeModel };
});
import { OrganizerRepository } from '../../../src/modules/organizer/organizer.repository.js';
describe(OrganizerRepository.name, () => {
let repo: OrganizerRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new OrganizerRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'fill-1', regimenName: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'fill-2', regimenName: 'Evening' }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('fill-1').toString('base64');
const result = await repo.findByHousehold('hh1', 'user-1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({
_id: `fill-${i}`,
regimenName: `R${i}`,
}));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
it('filters by regimenId', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { regimenId: 'reg-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by status', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { status: 'completed' as never, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('returns cursor as null when hasMore is false even with data', async () => {
const items = [{ _id: 'fill-1', regimenName: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('findById', () => {
it('returns fill by id and householdId', async () => {
const fill = { _id: 'fill-1', householdId: 'hh1', regimenName: 'Morning' };
mockFindOne.mockResolvedValue(fill);
const result = await repo.findById('fill-1', 'hh1');
expect(result).toEqual(fill);
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.findById('fill-missing', 'hh1');
expect(result).toBeNull();
});
});
describe('create', () => {
it('creates and returns organizer fill', async () => {
const data = {
householdId: 'hh1',
userId: 'user-1',
regimenId: 'reg-1',
regimenName: 'Morning',
numberOfDays: 7,
fillDate: new Date(),
items: [],
status: 'completed' as const,
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data as never);
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('updateStatus', () => {
it('updates and returns fill with new status', async () => {
const updated = { _id: 'fill-1', status: 'reversed' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.updateStatus('fill-1', 'hh1', 'reversed' as never);
expect(result).toEqual(updated);
});
it('returns null when fill not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.updateStatus('fill-missing', 'hh1', 'reversed' as never);
expect(result).toBeNull();
});
});
});

View file

@ -0,0 +1,485 @@
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 { OrganizerFillStatus } 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 { mockListFills, mockGetFillById, mockPreview, mockFill, mockUndoFill } = vi.hoisted(() => ({
mockListFills: vi.fn(),
mockGetFillById: vi.fn(),
mockPreview: vi.fn(),
mockFill: vi.fn(),
mockUndoFill: vi.fn(),
}));
vi.mock('../../../src/modules/organizer/organizer.repository.js', () => ({
OrganizerRepository: class {
create = vi.fn();
update = vi.fn();
findById = vi.fn();
findByHousehold = vi.fn();
updateStatus = vi.fn();
},
}));
vi.mock('../../../src/modules/organizer/organizer.service.js', () => ({
OrganizerService: class {
listFills = mockListFills;
getFillById = mockGetFillById;
preview = mockPreview;
fill = mockFill;
undoFill = mockUndoFill;
},
}));
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 organizerRoutes from '../../../src/modules/organizer/organizer.routes.js';
function makeFakeFill(overrides = {}) {
return {
_id: 'fill-1',
householdId: 'hh1',
userId: 'kc-1',
regimenId: 'reg-1',
regimenName: 'Daily Medications',
numberOfDays: 7,
fillDate: '2024-06-01T00:00:00.000Z',
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
},
],
status: OrganizerFillStatus.COMPLETED,
notes: null,
createdAt: '2024-06-01T00:00:00.000Z',
updatedAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
function makeFakePreview(overrides = {}) {
return {
regimenName: 'Daily Medications',
numberOfDays: 7,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityAvailable: 30,
isShort: false,
shortage: 0,
cabinetBreakdown: [
{
cabinetItemId: 'ci-1',
expirationDate: '2025-12-31T00:00:00.000Z',
quantityToTake: 7,
},
],
},
],
canFillCompletely: true,
hasShortages: false,
...overrides,
};
}
describe('organizer.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(organizerRoutes);
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/organizer/fills', () => {
it('returns paginated fill list', async () => {
const fill = makeFakeFill();
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].regimenName).toBe('Daily Medications');
expect(body.data[0].status).toBe(OrganizerFillStatus.COMPLETED);
expect(body.pagination.hasMore).toBe(false);
});
it('passes query parameters to service', async () => {
mockListFills.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills?regimenId=reg-1&status=completed&limit=10',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockListFills).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({
regimenId: 'reg-1',
status: OrganizerFillStatus.COMPLETED,
limit: 10,
}),
);
});
it('handles ObjectId and Date serialization in fill response', async () => {
const fill = makeFakeFill({
_id: { toString: () => 'fill-obj' },
fillDate: new Date('2024-06-01T00:00:00.000Z'),
createdAt: { toISOString: () => '2024-06-01T00:00:00.000Z' },
updatedAt: new Date('2024-06-02T00:00:00.000Z'),
});
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('fill-obj');
expect(body.data[0].fillDate).toBe('2024-06-01T00:00:00.000Z');
expect(body.data[0].createdAt).toBe('2024-06-01T00:00:00.000Z');
expect(body.data[0].updatedAt).toBe('2024-06-02T00:00:00.000Z');
});
it('omits notes from response when null', async () => {
const fill = makeFakeFill({ notes: null });
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].notes).toBeUndefined();
});
it('includes notes in response when present', async () => {
const fill = makeFakeFill({ notes: 'Refilled before holiday' });
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].notes).toBe('Refilled before holiday');
});
});
describe('GET /api/v1/households/:householdId/organizer/fills/:id', () => {
it('returns single fill by id', async () => {
const fill = makeFakeFill();
mockGetFillById.mockResolvedValue(fill);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills/fill-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('fill-1');
expect(body.regimenId).toBe('reg-1');
expect(body.items).toHaveLength(1);
expect(body.items[0].deductions).toHaveLength(1);
expect(body.items[0].deductions[0].cabinetItemId).toBe('ci-1');
});
it('passes id and householdId to service correctly', async () => {
const fill = makeFakeFill();
mockGetFillById.mockResolvedValue(fill);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills/fill-42',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockGetFillById).toHaveBeenCalledWith('fill-42', 'hh1');
});
});
describe('POST /api/v1/households/:householdId/organizer/preview', () => {
it('returns preview result with items and shortage info', async () => {
const preview = makeFakePreview();
mockPreview.mockResolvedValue(preview);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/preview',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 7 },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.regimenName).toBe('Daily Medications');
expect(body.canFillCompletely).toBe(true);
expect(body.hasShortages).toBe(false);
expect(body.items).toHaveLength(1);
expect(body.items[0].medicineName).toBe('Metformin');
expect(body.items[0].cabinetBreakdown).toHaveLength(1);
});
it('passes regimenId and numberOfDays to service', async () => {
const preview = makeFakePreview();
mockPreview.mockResolvedValue(preview);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/preview',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-99', numberOfDays: 14 },
});
expect(res.statusCode).toBe(200);
expect(mockPreview).toHaveBeenCalledWith('hh1', 'kc-1', 'reg-99', 14);
});
it('returns preview with hasShortages=true and isShort items', async () => {
const preview = makeFakePreview({
canFillCompletely: false,
hasShortages: true,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 14,
quantityAvailable: 5,
isShort: true,
shortage: 9,
cabinetBreakdown: [
{
cabinetItemId: 'ci-1',
expirationDate: '2025-12-31T00:00:00.000Z',
quantityToTake: 5,
},
],
},
],
});
mockPreview.mockResolvedValue(preview);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/preview',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 14 },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.hasShortages).toBe(true);
expect(body.canFillCompletely).toBe(false);
expect(body.items[0].isShort).toBe(true);
expect(body.items[0].shortage).toBe(9);
});
});
describe('POST /api/v1/households/:householdId/organizer/fill', () => {
it('creates fill and returns 201 with COMPLETED status', async () => {
const fill = makeFakeFill({ status: OrganizerFillStatus.COMPLETED });
mockFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body._id).toBe('fill-1');
expect(body.status).toBe(OrganizerFillStatus.COMPLETED);
});
it('returns 201 with PARTIAL status when wasShort items exist', async () => {
const fill = makeFakeFill({
status: OrganizerFillStatus.PARTIAL,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 14,
quantityTaken: 5,
wasShort: true,
shortage: 9,
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 5 }],
},
],
});
mockFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 14, allowPartial: true },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.status).toBe(OrganizerFillStatus.PARTIAL);
expect(body.items[0].wasShort).toBe(true);
expect(body.items[0].shortage).toBe(9);
});
it('returns 400 on invalid body (missing regimenId)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { numberOfDays: 7, allowPartial: false },
});
expect(res.statusCode).toBe(400);
});
it('passes correct args (householdId, userId, body) to service', async () => {
const fill = makeFakeFill();
mockFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' },
});
expect(res.statusCode).toBe(201);
expect(mockFill).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: false,
notes: 'test note',
}),
);
});
});
describe('POST /api/v1/households/:householdId/organizer/fills/:id/undo', () => {
it('reverses fill and returns 200 with REVERSED status', async () => {
const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED });
mockUndoFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fills/fill-1/undo',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('fill-1');
expect(body.status).toBe(OrganizerFillStatus.REVERSED);
});
it('passes correct args to service (householdId first, then id, then userId)', async () => {
const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED });
mockUndoFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fills/fill-42/undo',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockUndoFill).toHaveBeenCalledWith('hh1', 'fill-42', 'kc-1');
});
});
});

View file

@ -0,0 +1,649 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
CabinetEventType,
CabinetEventSourceType,
OrganizerFillStatus,
DosageFrequency,
} from '@meshitrack/shared';
const { mockSession } = vi.hoisted(() => ({
mockSession: {
startTransaction: vi.fn(),
commitTransaction: vi.fn(),
abortTransaction: vi.fn(),
endSession: vi.fn(),
},
}));
vi.mock('mongoose', () => {
return { default: { startSession: vi.fn().mockResolvedValue(mockSession) } };
});
import { OrganizerService } from '../../../src/modules/organizer/organizer.service.js';
describe(OrganizerService.name, () => {
const mockOrganizerRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
updateStatus: vi.fn(),
};
const mockRegimensService = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getActiveByUser: vi.fn(),
calculateBurnRates: vi.fn(),
};
const mockCabinetRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
getAggregateSummary: vi.fn(),
create: vi.fn(),
update: vi.fn(),
adjustQuantity: vi.fn(),
findExpiringSoon: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
discard: vi.fn(),
findActiveByMedicineForFEFO: vi.fn(),
};
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: OrganizerService;
beforeEach(() => {
vi.clearAllMocks();
service = new OrganizerService({
organizerRepository: mockOrganizerRepo as never,
regimensService: mockRegimensService as never,
cabinetRepository: mockCabinetRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
});
});
describe('listFills', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockOrganizerRepo.findByHousehold.mockResolvedValue(result);
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', {
limit: 20,
});
});
});
describe('getFillById', () => {
it('returns fill when found', async () => {
const fill = { _id: 'fill-1', regimenName: 'Morning' };
mockOrganizerRepo.findById.mockResolvedValue(fill);
const result = await service.getFillById('fill-1', 'hh1');
expect(result).toEqual(fill);
});
it('throws NotFoundError when not found', async () => {
mockOrganizerRepo.findById.mockResolvedValue(null);
await expect(service.getFillById('fill-missing', 'hh1')).rejects.toThrow(
'Organizer fill not found',
);
});
});
describe('preview', () => {
const makeRegimen = (overrides = {}) => ({
_id: 'reg-1',
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
},
],
...overrides,
});
it('returns preview with no shortages when stock is sufficient', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.regimenName).toBe('Morning Routine');
expect(result.numberOfDays).toBe(7);
expect(result.canFillCompletely).toBe(true);
expect(result.hasShortages).toBe(false);
expect(result.items).toHaveLength(1);
expect(result.items[0].medicineId).toBe('med-1');
expect(result.items[0].quantityNeeded).toBe(7);
expect(result.items[0].quantityAvailable).toBe(30);
expect(result.items[0].isShort).toBe(false);
expect(result.items[0].shortage).toBe(0);
expect(result.items[0].cabinetBreakdown).toHaveLength(1);
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7);
expect(result.items[0].cabinetBreakdown[0].quantityBefore).toBe(30);
});
it('returns preview with shortages when stock is insufficient', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.canFillCompletely).toBe(false);
expect(result.hasShortages).toBe(true);
expect(result.items[0].isShort).toBe(true);
expect(result.items[0].shortage).toBe(4);
expect(result.items[0].quantityAvailable).toBe(3);
});
it('handles FEFO allocation across multiple cabinet items', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: new Date('2026-06-01') },
{ _id: { toString: () => 'ci-2' }, quantity: 5, expirationDate: new Date('2026-12-01') },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.canFillCompletely).toBe(true);
expect(result.items[0].cabinetBreakdown).toHaveLength(2);
expect(result.items[0].cabinetBreakdown[0].cabinetItemId).toBe('ci-1');
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(3);
expect(result.items[0].cabinetBreakdown[1].cabinetItemId).toBe('ci-2');
expect(result.items[0].cabinetBreakdown[1].quantityToTake).toBe(4);
});
it('skips AS_NEEDED medications', async () => {
mockRegimensService.getById.mockResolvedValue(
makeRegimen({
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
customFrequencyPerDay: null,
},
{
medicineId: 'med-2',
medicineName: 'Ibuprofen',
dosage: 2,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
},
],
}),
);
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-2' }, quantity: 20, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.items).toHaveLength(1);
expect(result.items[0].medicineId).toBe('med-2');
});
it('throws BadRequestError when regimen is not active', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen({ isActive: false }));
await expect(service.preview('hh1', 'user-1', 'reg-1', 7)).rejects.toThrow(
'Regimen is not active',
);
});
it('handles null expirationDate in cabinet items', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 10, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.items[0].cabinetBreakdown[0].expirationDate).toBeNull();
});
it('handles empty cabinet (no items available)', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.canFillCompletely).toBe(false);
expect(result.hasShortages).toBe(true);
expect(result.items[0].quantityAvailable).toBe(0);
expect(result.items[0].shortage).toBe(7);
expect(result.items[0].cabinetBreakdown).toHaveLength(0);
});
it('uses customFrequencyPerDay when present', async () => {
mockRegimensService.getById.mockResolvedValue(
makeRegimen({
medications: [
{
medicineId: 'med-1',
medicineName: 'Custom Med',
dosage: 2,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 3,
},
],
}),
);
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 100, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
// dosage(2) * customFrequencyPerDay(3) * numberOfDays(7) = 42
expect(result.items[0].quantityNeeded).toBe(42);
});
it('stops taking from cabinet items once remaining is zero', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 7, expirationDate: null },
{ _id: { toString: () => 'ci-2' }, quantity: 10, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
// Needs 7, first item has 7 -- second item should not be touched
expect(result.items[0].cabinetBreakdown).toHaveLength(1);
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7);
});
});
describe('fill', () => {
const fillInput = {
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: false,
notes: 'Weekly fill',
};
const makeRegimen = () => ({
_id: 'reg-1',
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
},
],
});
it('executes fill successfully with no shortages', async () => {
// preview dependencies
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
householdId: 'hh1',
userId: 'user-1',
regimenId: 'reg-1',
regimenName: 'Morning Routine',
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.fill('hh1', 'user-1', fillInput);
expect(result.status).toBe(OrganizerFillStatus.COMPLETED);
expect(mockSession.startTransaction).toHaveBeenCalled();
expect(mockSession.commitTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', -7);
expect(mockCabinetEventsService.logEvents).toHaveBeenCalled();
});
it('throws BadRequestError when shortages exist and allowPartial is false', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
]);
await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow(
'Not enough stock to fill completely',
);
});
it('allows partial fill when allowPartial is true', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 0 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.PARTIAL,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true });
expect(result.status).toBe(OrganizerFillStatus.PARTIAL);
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: OrganizerFillStatus.PARTIAL,
}),
);
});
it('creates CONSUMED events for each deduction', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.fill('hh1', 'user-1', fillInput);
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: CabinetEventType.CONSUMED,
quantity: -7,
quantityBefore: 30,
quantityAfter: 23,
sourceType: CabinetEventSourceType.ORGANIZER_FILL,
sourceId: 'fill-1',
}),
);
});
it('handles adjustQuantity returning null (skips deduction)', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.PARTIAL,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true });
expect(result).toBeTruthy();
// No events logged since adjustQuantity returned null
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(0);
});
it('aborts transaction and rethrows on error', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure'));
await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow('DB failure');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('creates fill with notes when provided', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.fill('hh1', 'user-1', fillInput);
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
notes: 'Weekly fill',
}),
);
});
it('sets COMPLETED status when no items have shortages', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.fill('hh1', 'user-1', fillInput);
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: OrganizerFillStatus.COMPLETED,
}),
);
});
});
describe('undoFill', () => {
const makeFill = (overrides = {}) => ({
_id: 'fill-1',
householdId: 'hh1',
userId: 'user-1',
status: OrganizerFillStatus.COMPLETED,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
},
],
...overrides,
});
it('reverses fill and restores quantities', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...makeFill(),
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.undoFill('hh1', 'fill-1', 'user-1');
expect(result.status).toBe(OrganizerFillStatus.REVERSED);
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', 7);
expect(mockOrganizerRepo.updateStatus).toHaveBeenCalledWith(
'fill-1',
'hh1',
OrganizerFillStatus.REVERSED,
);
expect(mockSession.startTransaction).toHaveBeenCalled();
expect(mockSession.commitTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('creates RESTORED events for each deduction', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...makeFill(),
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.undoFill('hh1', 'fill-1', 'user-1');
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: CabinetEventType.RESTORED,
quantity: 7,
quantityBefore: 23,
quantityAfter: 30,
sourceType: CabinetEventSourceType.ORGANIZER_UNDO,
sourceId: 'fill-1',
}),
);
});
it('throws BadRequestError when fill is already reversed', async () => {
mockOrganizerRepo.findById.mockResolvedValue(
makeFill({ status: OrganizerFillStatus.REVERSED }),
);
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow(
'Fill has already been reversed',
);
});
it('throws NotFoundError when fill does not exist', async () => {
mockOrganizerRepo.findById.mockResolvedValue(null);
await expect(service.undoFill('hh1', 'fill-missing', 'user-1')).rejects.toThrow(
'Organizer fill not found',
);
});
it('aborts transaction and rethrows on error', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure'));
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow('DB failure');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('handles cabinet item not found when restoring (uses 0 as quantityBefore)', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue(null);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 7 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...makeFill(),
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.undoFill('hh1', 'fill-1', 'user-1');
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events[0].quantityBefore).toBe(0);
expect(events[0].quantityAfter).toBe(7);
});
it('throws NotFoundError when updateStatus returns null', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill({ items: [] }));
mockOrganizerRepo.updateStatus.mockResolvedValue(null);
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow(
'Organizer fill not found',
);
});
it('restores multiple deductions across items', async () => {
const fill = makeFill({
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [
{ cabinetItemId: 'ci-1', quantityTaken: 4 },
{ cabinetItemId: 'ci-2', quantityTaken: 3 },
],
},
{
medicineId: 'med-2',
medicineName: 'Aspirin',
quantityNeeded: 14,
quantityTaken: 14,
wasShort: false,
shortage: 0,
deductions: [{ cabinetItemId: 'ci-3', quantityTaken: 14 }],
},
],
});
mockOrganizerRepo.findById.mockResolvedValue(fill);
mockCabinetRepo.findById.mockResolvedValue({ quantity: 10 });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ quantity: 20 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...fill,
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.undoFill('hh1', 'fill-1', 'user-1');
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledTimes(3);
expect(mockCabinetRepo.findById).toHaveBeenCalledTimes(3);
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(3);
});
});
});

View file

@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest';
import { FreshnessCalculatorService } from '../../../src/modules/pantry/freshness-calculator.service.js';
import { ItemStatus, FreshnessUrgency, FreshnessSource, StorageLocation } from '@meshitrack/shared';
describe(FreshnessCalculatorService.name, () => {
const service = new FreshnessCalculatorService();
const baseItem = {
status: ItemStatus.SEALED,
storageLocation: StorageLocation.FRIDGE,
purchaseDate: new Date('2024-01-01'),
expirationDate: undefined,
openedDate: undefined,
preparedDate: undefined,
};
const rule = {
shelfLifeDays: 14,
openedLifeDays: 7,
freezerLifeDays: 90,
};
describe('calculate', () => {
it('uses packaging expiration date when present', () => {
const item = { ...baseItem, expirationDate: new Date('2099-12-31') };
const result = service.calculate(item, rule);
expect(result.source).toBe(FreshnessSource.PACKAGING);
expect(result.estimatedExpiryDate).toEqual(new Date('2099-12-31'));
});
it('falls back to 7-day default when no rule provided', () => {
const result = service.calculate(baseItem, null);
expect(result.source).toBe(FreshnessSource.RULE);
const expected = new Date('2024-01-01');
expected.setDate(expected.getDate() + 7);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('uses freezerLifeDays for freezer storage', () => {
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
const result = service.calculate(item, rule);
expect(result.source).toBe(FreshnessSource.RULE);
const expected = new Date('2024-01-01');
expected.setDate(expected.getDate() + 90);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('uses shelfLifeDays for sealed items in freezer without freezerLifeDays', () => {
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
const ruleNoFreezer = { shelfLifeDays: 14, openedLifeDays: 7 };
const result = service.calculate(item, ruleNoFreezer);
const expected = new Date('2024-01-01');
expected.setDate(expected.getDate() + 14);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('uses openedLifeDays for opened items', () => {
const openedDate = new Date('2024-01-05');
const item = {
...baseItem,
status: ItemStatus.OPENED,
openedDate,
};
const result = service.calculate(item, rule);
const expected = new Date('2024-01-05');
expected.setDate(expected.getDate() + 7);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('uses openedLifeDays for prepared items with openedDate', () => {
const openedDate = new Date('2024-01-05');
const item = {
...baseItem,
status: ItemStatus.PREPARED,
openedDate,
};
const result = service.calculate(item, rule);
const expected = new Date('2024-01-05');
expected.setDate(expected.getDate() + 7);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('uses shelfLifeDays for opened item without openedDate', () => {
const item = { ...baseItem, status: ItemStatus.OPENED };
const result = service.calculate(item, rule);
const expected = new Date('2024-01-01');
expected.setDate(expected.getDate() + 14);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('uses shelfLifeDays for sealed items', () => {
const result = service.calculate(baseItem, rule);
const expected = new Date('2024-01-01');
expected.setDate(expected.getDate() + 14);
expect(result.estimatedExpiryDate).toEqual(expected);
});
it('computes daysRemaining and urgency', () => {
const future = new Date();
future.setDate(future.getDate() + 10);
const item = { ...baseItem, expirationDate: future };
const result = service.calculate(item, rule);
expect(result.daysRemaining).toBeGreaterThan(5);
expect(result.urgency).toBe(FreshnessUrgency.FRESH);
});
});
describe('isActive', () => {
it('returns true for sealed', () => {
expect(service.isActive('sealed')).toBe(true);
});
it('returns true for opened', () => {
expect(service.isActive('opened')).toBe(true);
});
it('returns true for prepared', () => {
expect(service.isActive('prepared')).toBe(true);
});
it('returns false for consumed', () => {
expect(service.isActive('consumed')).toBe(false);
});
it('returns false for discarded', () => {
expect(service.isActive('discarded')).toBe(false);
});
it('returns false for expired', () => {
expect(service.isActive('expired')).toBe(false);
});
});
describe('mapUrgency', () => {
it('returns FRESH for > 5 days', () => {
expect(service.mapUrgency(6)).toBe(FreshnessUrgency.FRESH);
});
it('returns USE_SOON for 2-5 days', () => {
expect(service.mapUrgency(3)).toBe(FreshnessUrgency.USE_SOON);
});
it('returns URGENT for 0-1 days', () => {
expect(service.mapUrgency(1)).toBe(FreshnessUrgency.URGENT);
});
it('returns CHECK for -1 to -3 days', () => {
expect(service.mapUrgency(-1)).toBe(FreshnessUrgency.CHECK);
});
it('returns EXPIRED for < -3 days', () => {
expect(service.mapUrgency(-4)).toBe(FreshnessUrgency.EXPIRED);
});
it('returns USE_SOON for exactly 2', () => {
expect(service.mapUrgency(2)).toBe(FreshnessUrgency.USE_SOON);
});
it('returns USE_SOON for exactly 5', () => {
expect(service.mapUrgency(5)).toBe(FreshnessUrgency.USE_SOON);
});
it('returns URGENT for exactly 0', () => {
expect(service.mapUrgency(0)).toBe(FreshnessUrgency.URGENT);
});
it('returns CHECK for exactly -3', () => {
expect(service.mapUrgency(-3)).toBe(FreshnessUrgency.CHECK);
});
});
});

View file

@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockFind,
mockFindOne,
mockFindOneAndUpdate,
mockFindOneAndDelete,
mockSave,
mockAggregate,
mockUpdateMany,
mockFindByIdAndUpdate,
} = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockFindOneAndDelete: vi.fn(),
mockSave: vi.fn(),
mockAggregate: vi.fn(),
mockUpdateMany: vi.fn(),
mockFindByIdAndUpdate: vi.fn(),
}));
vi.mock('../../../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);
});
});
});

View file

@ -0,0 +1,393 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const {
mockFindByHousehold,
mockFindById,
mockFindExpiringSoon,
mockCreate,
mockUpdate,
mockDelete,
mockGetWasteStats,
mockGetTopWastedProducts,
mockFindByIds,
mockBulkUpdateStatus,
mockFindActiveByHousehold,
mockUpdateFreshness,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockFindExpiringSoon: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockGetWasteStats: vi.fn(),
mockGetTopWastedProducts: vi.fn(),
mockFindByIds: vi.fn(),
mockBulkUpdateStatus: vi.fn(),
mockFindActiveByHousehold: vi.fn(),
mockUpdateFreshness: vi.fn(),
}));
const { mockProductFindById } = vi.hoisted(() => ({
mockProductFindById: vi.fn(),
}));
const { mockFindApplicableRule } = vi.hoisted(() => ({
mockFindApplicableRule: vi.fn(),
}));
vi.mock('../../../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);
});
});
});

View file

@ -0,0 +1,434 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PantryService } from '../../../src/modules/pantry/pantry.service.js';
import { NotFoundError, BadRequestError } from '../../../src/common/errors.js';
import { ItemStatus } from '@meshitrack/shared';
const mockPantryRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findExpiringSoon: vi.fn(),
findActiveByHousehold: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateFreshness: vi.fn(),
delete: vi.fn(),
getWasteStats: vi.fn(),
getTopWastedProducts: vi.fn(),
findByIds: vi.fn(),
bulkUpdateStatus: vi.fn(),
};
const mockFreshnessRulesRepo = {
findApplicableRule: vi.fn(),
};
const mockProductsRepo = {
findById: vi.fn(),
findByIds: vi.fn(),
};
function makeItem(overrides: Record<string, unknown> = {}) {
return {
_id: { toString: () => 'item-1' },
householdId: 'hh1',
productId: 'p1',
productName: 'Milk',
storageLocation: 'fridge',
quantity: 1,
unit: 'piece',
purchaseDate: new Date('2024-01-01').toISOString(),
status: ItemStatus.SEALED,
freshnessEstimate: {
estimatedExpiryDate: new Date('2024-01-15').toISOString(),
daysRemaining: 14,
urgency: 'fresh',
source: 'rule',
},
createdBy: 'user-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function makeProduct() {
return {
_id: 'p1',
householdId: 'hh1',
name: 'Milk',
category: 'dairy',
servingSize: 250,
servingUnit: 'ml',
nutrition: { calories: 60, protein: 3, carbs: 5, fat: 3 },
};
}
describe(PantryService.name, () => {
let service: PantryService;
beforeEach(() => {
vi.clearAllMocks();
service = new PantryService({
pantryRepository: mockPantryRepo as never,
freshnessRulesRepository: mockFreshnessRulesRepo as never,
productsRepository: mockProductsRepo as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockPantryRepo.findByHousehold.mockResolvedValue(expected);
const result = await service.list('hh1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getById', () => {
it('returns item when found', async () => {
const item = makeItem();
mockPantryRepo.findById.mockResolvedValue(item);
const result = await service.getById('item-1', 'hh1');
expect(result).toEqual(item);
});
it('throws NotFoundError when not found', async () => {
mockPantryRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a pantry item', async () => {
const product = makeProduct();
mockProductsRepo.findById.mockResolvedValue(product);
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
shelfLifeDays: 14,
openedLifeDays: 7,
});
mockPantryRepo.create.mockResolvedValue(makeItem());
const result = await service.create(
{
productId: 'p1',
storageLocation: 'fridge' as never,
quantity: 1,
unit: 'piece' as never,
},
'hh1',
'user-1',
);
expect(mockPantryRepo.create).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('creates item with all optional fields', async () => {
mockProductsRepo.findById.mockResolvedValue(makeProduct());
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
mockPantryRepo.create.mockResolvedValue(makeItem());
await service.create(
{
productId: 'p1',
storageLocation: 'fridge' as never,
quantity: 2,
unit: 'piece' as never,
purchaseDate: '2024-01-01T00:00:00Z',
expirationDate: '2024-02-01T00:00:00Z',
notes: 'Organic',
purchasePrice: 4.99,
storeId: 's1',
},
'hh1',
'user-1',
);
expect(mockPantryRepo.create).toHaveBeenCalled();
});
it('throws NotFoundError when product not found', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(
service.create(
{
productId: 'missing',
storageLocation: 'fridge' as never,
quantity: 1,
unit: 'piece' as never,
},
'hh1',
'user-1',
),
).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('updates and returns item', async () => {
const item = makeItem();
mockPantryRepo.findById.mockResolvedValue(item);
mockPantryRepo.update.mockResolvedValue({ ...item, quantity: 3 });
const result = await service.update('item-1', 'hh1', { quantity: 3 });
expect((result as Record<string, unknown>).quantity).toBe(3);
});
it('throws NotFoundError when update returns null', async () => {
mockPantryRepo.findById.mockResolvedValue(makeItem());
mockPantryRepo.update.mockResolvedValue(null);
await expect(service.update('item-1', 'hh1', { quantity: 3 })).rejects.toThrow(NotFoundError);
});
});
describe('transition', () => {
it('transitions from sealed to opened', async () => {
const item = makeItem({ status: ItemStatus.SEALED });
mockPantryRepo.findById.mockResolvedValue(item);
mockProductsRepo.findById.mockResolvedValue(makeProduct());
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
shelfLifeDays: 14,
openedLifeDays: 7,
});
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });
const result = await service.transition('item-1', 'hh1', { status: 'opened' as never });
expect((result as Record<string, unknown>).status).toBe(ItemStatus.OPENED);
});
it('transitions from sealed to consumed', async () => {
const item = makeItem({ status: ItemStatus.SEALED });
mockPantryRepo.findById.mockResolvedValue(item);
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });
const result = await service.transition('item-1', 'hh1', { status: 'consumed' as never });
expect((result as Record<string, unknown>).status).toBe(ItemStatus.CONSUMED);
});
it('transitions from sealed to discarded', async () => {
const item = makeItem({ status: ItemStatus.SEALED });
mockPantryRepo.findById.mockResolvedValue(item);
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.DISCARDED });
const result = await service.transition('item-1', 'hh1', { status: 'discarded' as never });
expect((result as Record<string, unknown>).status).toBe(ItemStatus.DISCARDED);
});
it('transitions from opened to prepared', async () => {
const item = makeItem({ status: ItemStatus.OPENED });
mockPantryRepo.findById.mockResolvedValue(item);
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.PREPARED });
const result = await service.transition('item-1', 'hh1', { status: 'prepared' as never });
expect((result as Record<string, unknown>).status).toBe(ItemStatus.PREPARED);
});
it('rejects invalid transition', async () => {
const item = makeItem({ status: ItemStatus.CONSUMED });
mockPantryRepo.findById.mockResolvedValue(item);
await expect(
service.transition('item-1', 'hh1', { status: 'opened' as never }),
).rejects.toThrow(BadRequestError);
});
it('includes notes and date in transition', async () => {
const item = makeItem({ status: ItemStatus.SEALED });
mockPantryRepo.findById.mockResolvedValue(item);
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });
await service.transition('item-1', 'hh1', {
status: 'consumed' as never,
date: '2024-01-10T12:00:00Z',
notes: 'Used in cooking',
});
expect(mockPantryRepo.update).toHaveBeenCalled();
});
it('throws NotFoundError when update returns null', async () => {
const item = makeItem({ status: ItemStatus.SEALED });
mockPantryRepo.findById.mockResolvedValue(item);
mockPantryRepo.update.mockResolvedValue(null);
await expect(
service.transition('item-1', 'hh1', { status: 'consumed' as never }),
).rejects.toThrow(NotFoundError);
});
it('recalculates freshness when opening and product not found', async () => {
const item = makeItem({ status: ItemStatus.SEALED });
mockPantryRepo.findById.mockResolvedValue(item);
mockProductsRepo.findById.mockResolvedValue(null);
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });
await service.transition('item-1', 'hh1', { status: 'opened' as never });
expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
'hh1',
'other',
'fridge',
);
});
});
describe('batchTransition', () => {
it('transitions valid items', async () => {
const items = [
makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED }),
makeItem({ _id: { toString: () => 'id2' }, status: ItemStatus.OPENED }),
];
mockPantryRepo.findByIds.mockResolvedValue(items);
mockPantryRepo.bulkUpdateStatus.mockResolvedValue(2);
const result = await service.batchTransition('hh1', {
itemIds: ['id1', 'id2'],
status: 'consumed' as never,
});
expect(result.transitioned).toBe(2);
expect(result.failed).toBe(0);
});
it('skips items with invalid transitions', async () => {
const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.CONSUMED })];
mockPantryRepo.findByIds.mockResolvedValue(items);
const result = await service.batchTransition('hh1', {
itemIds: ['id1'],
status: 'consumed' as never,
});
expect(result.transitioned).toBe(0);
expect(result.failed).toBe(1);
});
it('passes date and notes as extra', async () => {
const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED })];
mockPantryRepo.findByIds.mockResolvedValue(items);
mockPantryRepo.bulkUpdateStatus.mockResolvedValue(1);
await service.batchTransition('hh1', {
itemIds: ['id1'],
status: 'discarded' as never,
date: '2024-01-10T00:00:00Z',
notes: 'Expired',
});
expect(mockPantryRepo.bulkUpdateStatus).toHaveBeenCalled();
});
});
describe('getExpiringSoon', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockPantryRepo.findExpiringSoon.mockResolvedValue(expected);
const result = await service.getExpiringSoon('hh1', { days: 7, limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getWasteStats', () => {
it('computes waste stats for a period', async () => {
mockPantryRepo.getWasteStats.mockResolvedValue([{ totalConsumed: 8, totalDiscarded: 2 }]);
mockPantryRepo.getTopWastedProducts.mockResolvedValue([
{ productId: 'p1', productName: 'Milk', count: 2 },
]);
const result = await service.getWasteStats('hh1', { period: 'month' });
expect(result.totalItemsConsumed).toBe(8);
expect(result.totalItemsDiscarded).toBe(2);
expect(result.wastePercentage).toBe(20);
expect(result.topWastedProducts).toHaveLength(1);
});
it('handles no data', async () => {
mockPantryRepo.getWasteStats.mockResolvedValue([]);
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
const result = await service.getWasteStats('hh1', { period: 'week' });
expect(result.totalItemsConsumed).toBe(0);
expect(result.totalItemsDiscarded).toBe(0);
expect(result.wastePercentage).toBe(0);
});
it('handles quarter period', async () => {
mockPantryRepo.getWasteStats.mockResolvedValue([]);
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
const result = await service.getWasteStats('hh1', { period: 'quarter' });
expect(result.period.start).toBeDefined();
});
it('handles year period', async () => {
mockPantryRepo.getWasteStats.mockResolvedValue([]);
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
const result = await service.getWasteStats('hh1', { period: 'year' });
expect(result.period.start).toBeDefined();
});
});
describe('refreshAllFreshness', () => {
it('refreshes all active items', async () => {
const item = makeItem();
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
mockProductsRepo.findById.mockResolvedValue(makeProduct());
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
shelfLifeDays: 14,
openedLifeDays: 7,
});
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
await service.refreshAllFreshness('hh1');
expect(mockPantryRepo.updateFreshness).toHaveBeenCalledTimes(1);
});
it('marks items as expired when urgency is expired', async () => {
const item = makeItem({
purchaseDate: new Date('2020-01-01').toISOString(),
});
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
mockProductsRepo.findById.mockResolvedValue(makeProduct());
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
shelfLifeDays: 1,
openedLifeDays: 1,
});
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
await service.refreshAllFreshness('hh1');
const updateCall = mockPantryRepo.updateFreshness.mock.calls[0];
expect(updateCall?.[2]).toBe(ItemStatus.EXPIRED);
});
it('handles missing product gracefully', async () => {
const item = makeItem();
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
mockProductsRepo.findById.mockResolvedValue(null);
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
await service.refreshAllFreshness('hh1');
expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
'hh1',
'other',
'fridge',
);
});
});
describe('delete', () => {
it('deletes item', async () => {
mockPantryRepo.findById.mockResolvedValue(makeItem());
mockPantryRepo.delete.mockResolvedValue(makeItem());
const result = await service.delete('item-1', 'hh1');
expect(result).toBeDefined();
});
it('throws NotFoundError when not found', async () => {
mockPantryRepo.findById.mockResolvedValue(null);
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -0,0 +1,162 @@
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);
});
});
});

View file

@ -0,0 +1,218 @@
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);
});
});
});

View file

@ -0,0 +1,175 @@
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();
});
});
});

View file

@ -0,0 +1,487 @@
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,
}),
}),
);
});
});

View file

@ -0,0 +1,173 @@
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');
});
});

View file

@ -0,0 +1,298 @@
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 },
);
});
});
});

View file

@ -0,0 +1,579 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const {
mockFindByHousehold,
mockFindById,
mockFindByBarcode,
mockFindDuplicate,
mockCreate,
mockUpdate,
mockSoftDelete,
mockBulkCreate,
mockBarcodeLookup,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockFindByBarcode: vi.fn(),
mockFindDuplicate: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
mockBulkCreate: vi.fn(),
mockBarcodeLookup: vi.fn(),
}));
vi.mock('../../../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);
});
});
});

View file

@ -0,0 +1,297 @@
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' });
});
});
});

View file

@ -0,0 +1,257 @@
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([]);
});
});
});

View file

@ -0,0 +1,471 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { 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');
});
});
});

View file

@ -0,0 +1,432 @@
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);
});
});
});

View file

@ -0,0 +1,279 @@
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);
});
});
});

View file

@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
}));
vi.mock('../../../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);
});
});
});

View file

@ -0,0 +1,471 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const {
mockFindByHousehold,
mockFindById,
mockFindByProductId,
mockFindAllByProductId,
mockCreate,
mockUpdate,
mockSoftDelete,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockFindByProductId: vi.fn(),
mockFindAllByProductId: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
}));
const { mockFindByIds } = vi.hoisted(() => ({
mockFindByIds: vi.fn(),
}));
vi.mock('../../../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);
});
});
});

View file

@ -0,0 +1,344 @@
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();
});
});
});

View file

@ -0,0 +1,132 @@
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');
}
});
});
});

View file

@ -0,0 +1,207 @@
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/refill-list.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;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
}
return { RefillListModel: FakeModel };
});
import { RefillsRepository } from '../../../src/modules/refills/refills.repository.js';
describe(RefillsRepository.name, () => {
let repo: RefillsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new RefillsRepository();
});
describe('create', () => {
it('saves and returns refill list', async () => {
const data = {
householdId: 'hh1',
name: 'Monthly Refills',
status: 'active',
createdBy: 'user-1',
items: [],
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data);
expect(mockSave).toHaveBeenCalled();
expect(result).toBeTruthy();
});
});
describe('findByHousehold', () => {
it('returns paginated lists', async () => {
const lists = [{ _id: 'rl-1', name: 'Monthly Refills' }];
mockFind.mockResolvedValue(lists);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.data).toEqual(lists);
expect(result.pagination.hasMore).toBe(false);
expect(result.pagination.cursor).toBeNull();
});
it('sets hasMore when more lists exist', async () => {
const lists = [{ _id: 'rl-1' }, { _id: 'rl-2' }, { _id: 'rl-3' }];
mockFind.mockResolvedValue(lists);
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('handles cursor pagination', async () => {
mockFind.mockResolvedValue([]);
const cursor = Buffer.from('rl-1').toString('base64');
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(result.pagination.hasMore).toBe(false);
});
it('filters by status', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { status: 'active' as never, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
});
});
describe('findById', () => {
it('returns list when found', async () => {
const list = { _id: 'rl-1', name: 'Monthly Refills' };
mockFindOne.mockResolvedValue(list);
const result = await repo.findById('rl-1', 'hh1');
expect(result).toEqual(list);
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
expect(await repo.findById('missing', 'hh1')).toBeNull();
});
});
describe('update', () => {
it('updates and returns list', async () => {
const updated = { _id: 'rl-1', name: 'Updated' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.update('rl-1', 'hh1', { name: 'Updated' });
expect(result).toEqual(updated);
});
it('updates status field', async () => {
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', status: 'shopping' });
const result = await repo.update('rl-1', 'hh1', { status: 'shopping' as never });
expect(result).toBeTruthy();
});
it('updates preferredStoreId field', async () => {
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', preferredStoreId: 'st-1' });
const result = await repo.update('rl-1', 'hh1', { preferredStoreId: 'st-1' });
expect(result).toBeTruthy();
});
it('returns null when not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
expect(await repo.update('missing', 'hh1', {})).toBeNull();
});
});
describe('updateItem', () => {
it('updates item and returns list', async () => {
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
expect(result).toEqual(updated);
});
it('updates actualPrice, storeId, and notes', async () => {
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] });
await repo.updateItem('rl-1', 'hh1', 'item-1', {
actualPrice: 9.99,
storeId: 'st-1',
notes: 'picked up at CVS',
});
expect(mockFindOneAndUpdate).toHaveBeenCalled();
});
it('includes checkedAt when provided', async () => {
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] });
const checkedAt = new Date();
await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true, checkedAt });
expect(mockFindOneAndUpdate).toHaveBeenCalled();
});
});
describe('markItemsAddedToCabinet', () => {
it('marks items and returns list', async () => {
const updated = { _id: 'rl-1', items: [] };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.markItemsAddedToCabinet('rl-1', 'hh1', ['item-1', 'item-2']);
expect(result).toEqual(updated);
});
});
});

View file

@ -0,0 +1,522 @@
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 {
mockGetAlerts,
mockCreateList,
mockList,
mockGetById,
mockUpdateList,
mockUpdateItem,
mockAddToCabinet,
mockGetStoreComparison,
} = vi.hoisted(() => ({
mockGetAlerts: vi.fn(),
mockCreateList: vi.fn(),
mockList: vi.fn(),
mockGetById: vi.fn(),
mockUpdateList: vi.fn(),
mockUpdateItem: vi.fn(),
mockAddToCabinet: vi.fn(),
mockGetStoreComparison: vi.fn(),
}));
vi.mock('../../../src/modules/refills/refills.repository.js', () => ({
RefillsRepository: class {
create = vi.fn();
findByHousehold = vi.fn();
findById = vi.fn();
update = vi.fn();
updateItem = vi.fn();
markItemsAddedToCabinet = vi.fn();
},
}));
vi.mock('../../../src/modules/refills/refills.service.js', () => ({
RefillsService: class {
getAlerts = mockGetAlerts;
createList = mockCreateList;
list = mockList;
getById = mockGetById;
updateList = mockUpdateList;
updateItem = mockUpdateItem;
addToCabinet = mockAddToCabinet;
getStoreComparison = mockGetStoreComparison;
},
}));
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 refillsRoutes from '../../../src/modules/refills/refills.routes.js';
function makeFakeRefillList(overrides = {}) {
return {
_id: 'rl-1',
householdId: 'hh1',
name: 'Monthly Refills',
items: [],
status: 'active',
createdBy: 'kc-1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
describe('refills.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(refillsRoutes);
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/refills/alerts', () => {
it('returns alerts with price options', async () => {
mockGetAlerts.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
daysUntilEmpty: 3,
dailyConsumption: 2,
currentStock: 6,
suggestedQuantity: 60,
lastKnownPrice: {
price: 10,
pricePerUnit: 0.1,
storeName: 'CVS',
storeId: 'st-1',
date: new Date('2026-01-01T00:00:00.000Z'),
},
cheapestOption: {
price: 8,
pricePerUnit: 0.08,
storeName: 'Walmart',
storeId: 'st-2',
date: new Date('2026-01-02T00:00:00.000Z'),
},
},
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/alerts',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].lastKnownPrice.storeName).toBe('CVS');
expect(body.data[0].lastKnownPrice.date).toBe('2026-01-01T00:00:00.000Z');
expect(body.data[0].cheapestOption.storeName).toBe('Walmart');
});
it('returns alerts', async () => {
mockGetAlerts.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
daysUntilEmpty: 3,
dailyConsumption: 2,
currentStock: 6,
suggestedQuantity: 60,
},
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/alerts',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].medicineName).toBe('Aspirin');
});
it('uses requesting user by default', async () => {
mockGetAlerts.mockResolvedValue([]);
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/alerts',
headers: authHeaders,
});
expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'kc-1', 7);
});
it('uses userId query param when provided', async () => {
mockGetAlerts.mockResolvedValue([]);
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/alerts?userId=other-user&thresholdDays=14',
headers: authHeaders,
});
expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'other-user', 14);
});
});
describe('POST /api/v1/households/:householdId/refills/lists', () => {
it('creates list and returns 201', async () => {
mockCreateList.mockResolvedValue(makeFakeRefillList());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/refills/lists',
headers: authHeaders,
payload: { name: 'Monthly Refills', fromAlerts: false, thresholdDays: 7 },
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Monthly Refills');
});
it('passes householdId and userId to service', async () => {
mockCreateList.mockResolvedValue(makeFakeRefillList());
await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/refills/lists',
headers: authHeaders,
payload: { name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
});
expect(mockCreateList).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Auto List', fromAlerts: true }),
'hh1',
'kc-1',
);
});
it('returns 400 for missing name', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/refills/lists',
headers: authHeaders,
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
describe('GET /api/v1/households/:householdId/refills/lists', () => {
it('returns paginated lists', async () => {
mockList.mockResolvedValue({
data: [makeFakeRefillList()],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.pagination.hasMore).toBe(false);
});
it('includes optional list fields in response', async () => {
mockList.mockResolvedValue({
data: [
makeFakeRefillList({
preferredStoreId: 'st-1',
totalEstimatedCost: 25.5,
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
estimatedPrice: 10,
actualPrice: 9.5,
checked: true,
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
addedToCabinet: false,
storeId: 'st-1',
notes: 'generic brand',
},
],
}),
],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].preferredStoreId).toBe('st-1');
expect(body.data[0].totalEstimatedCost).toBe(25.5);
const item = body.data[0].items[0];
expect(item.estimatedPrice).toBe(10);
expect(item.actualPrice).toBe(9.5);
expect(item.checkedAt).toBe('2026-01-10T00:00:00.000Z');
expect(item.storeId).toBe('st-1');
expect(item.notes).toBe('generic brand');
});
});
describe('GET /api/v1/households/:householdId/refills/lists/:id', () => {
it('returns single list', async () => {
mockGetById.mockResolvedValue(makeFakeRefillList());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists/rl-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Monthly Refills');
});
it('handles ObjectId-style _id in list and items', async () => {
mockGetById.mockResolvedValue(
makeFakeRefillList({
_id: { toString: () => 'rl-obj' },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
items: [
{
_id: { toString: () => 'item-obj' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
}),
);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists/rl-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('rl-obj');
expect(body.items[0]._id).toBe('item-obj');
expect(body.createdAt).toBe('2026-01-01T00:00:00.000Z');
});
it('passes id and householdId to service', async () => {
mockGetById.mockResolvedValue(makeFakeRefillList());
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists/rl-99',
headers: authHeaders,
});
expect(mockGetById).toHaveBeenCalledWith('rl-99', 'hh1');
});
});
describe('PATCH /api/v1/households/:householdId/refills/lists/:id', () => {
it('updates list and returns 200', async () => {
mockUpdateList.mockResolvedValue(makeFakeRefillList({ name: 'Updated' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/refills/lists/rl-1',
headers: authHeaders,
payload: { name: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated');
});
it('passes id, householdId, body to service', async () => {
mockUpdateList.mockResolvedValue(makeFakeRefillList());
await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/refills/lists/rl-1',
headers: authHeaders,
payload: { status: 'shopping' },
});
expect(mockUpdateList).toHaveBeenCalledWith(
'rl-1',
'hh1',
expect.objectContaining({ status: 'shopping' }),
);
});
});
describe('PATCH /api/v1/households/:householdId/refills/lists/:id/items/:itemId', () => {
it('updates item and returns 200', async () => {
mockUpdateItem.mockResolvedValue(makeFakeRefillList());
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-1',
headers: authHeaders,
payload: { checked: true },
});
expect(res.statusCode).toBe(200);
});
it('passes listId, householdId, itemId, body to service', async () => {
mockUpdateItem.mockResolvedValue(makeFakeRefillList());
await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-99',
headers: authHeaders,
payload: { actualPrice: 9.99 },
});
expect(mockUpdateItem).toHaveBeenCalledWith(
'rl-1',
'hh1',
'item-99',
expect.objectContaining({ actualPrice: 9.99 }),
);
});
});
describe('POST /api/v1/households/:householdId/refills/lists/:id/add-to-cabinet', () => {
it('adds items to cabinet and returns summary', async () => {
mockAddToCabinet.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 0 });
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.addedCount).toBe(2);
expect(body.priceRecordsCreated).toBe(0);
});
it('passes listId, householdId, userId to service', async () => {
mockAddToCabinet.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 });
await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet',
headers: authHeaders,
});
expect(mockAddToCabinet).toHaveBeenCalledWith('rl-1', 'hh1', 'kc-1');
});
});
describe('GET /api/v1/households/:householdId/refills/lists/:id/store-comparison', () => {
it('returns store comparison data', async () => {
mockGetStoreComparison.mockResolvedValue([
{
medicineId: 'med-1',
storeOptions: [
{
storeId: 'st-1',
storeName: 'CVS',
latestPrice: 8,
latestPricePerUnit: 0.08,
currency: 'USD',
date: new Date('2026-01-01T00:00:00.000Z'),
isInsurancePrice: false,
},
],
},
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists/rl-1/store-comparison',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].storeOptions[0].date).toBe('2026-01-01T00:00:00.000Z');
});
it('passes listId and householdId to service', async () => {
mockGetStoreComparison.mockResolvedValue([]);
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/refills/lists/rl-99/store-comparison',
headers: authHeaders,
});
expect(mockGetStoreComparison).toHaveBeenCalledWith('rl-99', 'hh1');
});
});
});

View file

@ -0,0 +1,506 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RefillsService } from '../../../src/modules/refills/refills.service.js';
describe(RefillsService.name, () => {
const mockRepo = {
create: vi.fn(),
findByHousehold: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
updateItem: vi.fn(),
markItemsAddedToCabinet: vi.fn(),
};
const mockRegimensService = {
calculateBurnRates: vi.fn(),
};
const mockCabinetRepo = {
getAggregateSummary: vi.fn(),
};
const mockCabinetService = {
addItem: vi.fn(),
};
const mockPricesRepo = {
getLatestForMedicine: vi.fn(),
compareStores: vi.fn(),
};
const mockPurchasesRepo = {
getPendingMedicineStock: vi.fn(),
};
let service: RefillsService;
beforeEach(() => {
vi.clearAllMocks();
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
service = new RefillsService({
refillsRepository: mockRepo as never,
regimensService: mockRegimensService as never,
cabinetRepository: mockCabinetRepo as never,
cabinetService: mockCabinetService as never,
medicinePricesRepository: mockPricesRepo as never,
purchasesRepository: mockPurchasesRepo as never,
});
});
describe('getAlerts', () => {
it('returns empty array when no medicines are running low', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 1,
totalInCabinet: 100,
daysUntilEmpty: 100,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result).toEqual([]);
});
it('returns alerts for medicines below threshold', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 10,
daysUntilEmpty: 5,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].daysUntilEmpty).toBe(5);
expect(result[0].suggestedQuantity).toBe(60); // ceil(2 * 30)
});
it('attaches lastKnownPrice when available', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
price: 10,
pricePerUnit: 0.1,
storeName: 'Walgreens',
storeId: 'st-1',
date: new Date('2026-01-01T00:00:00.000Z'),
});
mockPricesRepo.compareStores.mockResolvedValue([]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result[0].lastKnownPrice).toBeDefined();
expect(result[0].lastKnownPrice?.storeName).toBe('Walgreens');
});
it('attaches cheapestOption from compareStores', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([
{
storeId: 'st-1',
storeName: 'CVS',
latestPrice: 8,
latestPricePerUnit: 0.08,
currency: 'USD',
date: new Date(),
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result[0].cheapestOption).toBeDefined();
expect(result[0].cheapestOption?.storeName).toBe('CVS');
});
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 4,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
{ medicineId: 'med-1', totalUnits: 60 },
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result[0].pendingOrderStock).toBe(60);
expect(result[0].daysUntilEmptyWithOrders).toBe(32); // (4 + 60) / 2
});
it('excludes medicines with null daysUntilEmpty', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 0,
daysUntilEmpty: null,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
expect(result).toHaveLength(0);
});
});
describe('createList', () => {
it('creates list with provided items', async () => {
const list = { _id: 'rl-1', name: 'My List', items: [], status: 'active' };
mockRepo.create.mockResolvedValue(list);
const result = await service.createList(
{
name: 'My List',
fromAlerts: false,
thresholdDays: 7,
items: [
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never },
],
},
'hh1',
'user-1',
);
expect(result).toEqual(list);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'My List', householdId: 'hh1' }),
);
});
it('creates list with no items when neither fromAlerts nor items provided', async () => {
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList(
{ name: 'Empty List', fromAlerts: false, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
);
});
it('computes totalEstimatedCost from items with estimatedPrice', async () => {
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList(
{
name: 'Priced List',
fromAlerts: false,
thresholdDays: 7,
items: [
{
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet' as never,
estimatedPrice: 10,
},
{
medicineId: 'med-2',
medicineName: 'Ibuprofen',
quantity: 20,
unit: 'tablet' as never,
estimatedPrice: 8,
},
],
},
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ totalEstimatedCost: 18 }),
);
});
it('creates list from alerts when fromAlerts is true', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList(
{ name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ medicineId: 'med-1' })]),
}),
);
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', { limit: 20 });
expect(response).toEqual(result);
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
});
});
describe('getById', () => {
it('returns list when found', async () => {
const list = { _id: 'rl-1', name: 'My List' };
mockRepo.findById.mockResolvedValue(list);
expect(await service.getById('rl-1', 'hh1')).toEqual(list);
});
it('throws NotFoundError when not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Refill list not found');
});
});
describe('updateList', () => {
it('updates and returns list', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
const updated = { _id: 'rl-1', name: 'Updated' };
mockRepo.update.mockResolvedValue(updated);
const result = await service.updateList('rl-1', 'hh1', { name: 'Updated' });
expect(result).toEqual(updated);
});
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when update returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
mockRepo.update.mockResolvedValue(null);
await expect(service.updateList('rl-1', 'hh1', {})).rejects.toThrow('Refill list not found');
});
});
describe('updateItem', () => {
it('updates item and returns list', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
mockRepo.updateItem.mockResolvedValue(updated);
const result = await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
expect(result).toEqual(updated);
});
it('sets checkedAt when checked is true', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
mockRepo.updateItem.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
expect(mockRepo.updateItem).toHaveBeenCalledWith(
'rl-1',
'hh1',
'item-1',
expect.objectContaining({ checkedAt: expect.any(Date) }),
);
});
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when item not found', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
mockRepo.updateItem.mockResolvedValue(null);
await expect(service.updateItem('rl-1', 'hh1', 'bad-item', {})).rejects.toThrow(
'Refill list or item not found',
);
});
});
describe('addToCabinet', () => {
it('adds checked items to cabinet', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(result.addedCount).toBe(1);
expect(mockCabinetService.addItem).toHaveBeenCalledTimes(1);
});
it('computes unitPrice when actualPrice is set', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
actualPrice: 9,
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(mockCabinetService.addItem).toHaveBeenCalledWith(
expect.objectContaining({ unitPrice: 0.3, totalPrice: 9 }),
'hh1',
'user-1',
);
});
it('returns zero count when no checked items', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
});
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(result.addedCount).toBe(0);
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
});
it('skips already-added items', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: true,
},
],
});
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
expect(result.addedCount).toBe(0);
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
});
});
describe('getStoreComparison', () => {
it('returns store comparisons for list items', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [{ medicineId: 'med-1' }, { medicineId: 'med-2' }],
});
mockPricesRepo.compareStores
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'CVS' }])
.mockResolvedValueOnce([]);
const result = await service.getStoreComparison('rl-1', 'hh1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
});
it('deduplicates medicine ids', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [{ medicineId: 'med-1' }, { medicineId: 'med-1' }],
});
mockPricesRepo.compareStores.mockResolvedValue([{ storeId: 'st-1', storeName: 'CVS' }]);
const result = await service.getStoreComparison('rl-1', 'hh1');
expect(mockPricesRepo.compareStores).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,230 @@
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/regimen.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;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
}
return { RegimenModel: FakeModel };
});
import { RegimensRepository } from '../../../src/modules/regimens/regimens.repository.js';
describe(RegimensRepository.name, () => {
let repo: RegimensRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new RegimensRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'reg-1', name: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'reg-2', name: 'Evening' }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('reg-1').toString('base64');
const result = await repo.findByHousehold('hh1', 'user-1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `reg-${i}`, name: `Reg ${i}` }));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
it('filters by isActive', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { isActive: true, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('does not add isActive filter when undefined', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('returns cursor only when hasMore is true', async () => {
const items = [{ _id: 'reg-1', name: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('findById', () => {
it('returns regimen by id, householdId, and userId', async () => {
const regimen = { _id: 'reg-1', householdId: 'hh1', userId: 'user-1', name: 'Morning' };
mockFindOne.mockResolvedValue(regimen);
const result = await repo.findById('reg-1', 'hh1', 'user-1');
expect(result).toEqual(regimen);
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.findById('reg-missing', 'hh1', 'user-1');
expect(result).toBeNull();
});
});
describe('findActiveByUser', () => {
it('returns active regimens for user', async () => {
const regimens = [
{ _id: 'reg-1', isActive: true },
{ _id: 'reg-2', isActive: true },
];
mockFind.mockResolvedValue(regimens);
const result = await repo.findActiveByUser('hh1', 'user-1');
expect(result).toEqual(regimens);
});
it('returns empty array when no active regimens', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findActiveByUser('hh1', 'user-1');
expect(result).toEqual([]);
});
});
describe('create', () => {
it('creates and returns regimen', async () => {
const data = {
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
},
],
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data, 'hh1', 'user-1', 'user-1');
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('update', () => {
it('updates and returns regimen', async () => {
const updated = { _id: 'reg-1', name: 'Updated' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.update('reg-1', 'hh1', 'user-1', { name: 'Updated' });
expect(result).toEqual(updated);
});
it('returns null when regimen not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' });
expect(result).toBeNull();
});
});
describe('softDelete', () => {
it('soft deletes and returns regimen', async () => {
const deleted = { _id: 'reg-1', isDeleted: true };
mockFindOneAndUpdate.mockResolvedValue(deleted);
const result = await repo.softDelete('reg-1', 'hh1', 'user-1');
expect(result).toEqual(deleted);
});
it('returns null when regimen not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.softDelete('reg-missing', 'hh1', 'user-1');
expect(result).toBeNull();
});
});
});

View file

@ -0,0 +1,494 @@
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 { DosageFrequency, DosageUnit, StrengthUnit, MedicineForm } 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 { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } =
vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockCalculateBurnRates: vi.fn(),
}));
vi.mock('../../../src/modules/regimens/regimens.repository.js', () => ({
RegimensRepository: class {
findByHousehold = vi.fn();
findById = vi.fn();
findActiveByUser = vi.fn();
create = vi.fn();
update = vi.fn();
softDelete = vi.fn();
delete = vi.fn();
},
}));
vi.mock('../../../src/modules/regimens/regimens.service.js', () => ({
RegimensService: class {
list = mockList;
getById = mockGetById;
create = mockCreate;
update = mockUpdate;
delete = mockDelete;
getActiveByUser = vi.fn();
calculateBurnRates = mockCalculateBurnRates;
},
}));
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 regimensRoutes from '../../../src/modules/regimens/regimens.routes.js';
function makeFakeRegimen(overrides = {}) {
return {
_id: 'reg-1',
householdId: 'hh1',
userId: 'kc-1',
name: 'Daily Medications',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: StrengthUnit.MG,
medicineForm: MedicineForm.TABLET,
dosage: 1,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
timeOfDay: null,
instructions: null,
},
],
createdBy: 'kc-1',
createdAt: '2024-06-01T00:00:00.000Z',
updatedAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
const validPostBody = {
name: 'Daily Medications',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
},
],
};
describe('regimens.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(regimensRoutes);
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/regimens', () => {
it('returns paginated list of regimens', async () => {
const regimen = makeFakeRegimen();
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe('Daily Medications');
expect(body.data[0].isActive).toBe(true);
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const regimen = makeFakeRegimen({
_id: { toString: () => 'reg-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
});
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('reg-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].updatedAt).toBe('2024-01-02T00:00:00.000Z');
});
it('handles actual Date objects for createdAt/updatedAt', async () => {
const regimen = makeFakeRegimen({
createdAt: new Date('2024-03-01T00:00:00.000Z'),
updatedAt: new Date('2024-03-02T00:00:00.000Z'),
});
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].createdAt).toBe('2024-03-01T00:00:00.000Z');
expect(body.data[0].updatedAt).toBe('2024-03-02T00:00:00.000Z');
});
it('includes optional medication fields when present', async () => {
const regimen = makeFakeRegimen({
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: StrengthUnit.MG,
medicineForm: MedicineForm.TABLET,
dosage: 2,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 4,
timeOfDay: 'morning',
instructions: 'Take with food',
},
],
});
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
const med = body.data[0].medications[0];
expect(med.customFrequencyPerDay).toBe(4);
expect(med.timeOfDay).toBe('morning');
expect(med.instructions).toBe('Take with food');
});
it('omits null optional medication fields from response', async () => {
const regimen = makeFakeRegimen();
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
const med = body.data[0].medications[0];
expect(med.customFrequencyPerDay).toBeUndefined();
expect(med.timeOfDay).toBeUndefined();
expect(med.instructions).toBeUndefined();
});
it('passes query parameters to service', async () => {
mockList.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens?isActive=true&limit=5',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockList).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({ isActive: true, limit: 5 }),
);
});
});
describe('GET /api/v1/households/:householdId/regimens/burn-rate', () => {
it('returns burn rate data array', async () => {
const burnRateItem = {
medicineId: 'med-1',
medicineName: 'Metformin',
dailyConsumption: 1,
totalInCabinet: 30,
daysUntilEmpty: 30,
earliestExpiry: '2025-01-01T00:00:00.000Z',
avgUnitPrice: 2.5,
projectedDailyCost: 2.5,
projectedMonthlyCost: 75,
projectedYearlyCost: 912.5,
currency: 'USD',
};
mockCalculateBurnRates.mockResolvedValue([burnRateItem]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/burn-rate',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].medicineId).toBe('med-1');
expect(body.data[0].medicineName).toBe('Metformin');
expect(body.data[0].dailyConsumption).toBe(1);
expect(body.data[0].daysUntilEmpty).toBe(30);
expect(body.data[0].currency).toBe('USD');
});
it('returns empty array when no active regimens', async () => {
mockCalculateBurnRates.mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/burn-rate',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(0);
});
it('handles null monetary fields correctly', async () => {
const burnRateItem = {
medicineId: 'med-1',
medicineName: 'Metformin',
dailyConsumption: 1,
totalInCabinet: 30,
daysUntilEmpty: 30,
earliestExpiry: null,
avgUnitPrice: null,
projectedDailyCost: null,
projectedMonthlyCost: null,
projectedYearlyCost: null,
currency: null,
};
mockCalculateBurnRates.mockResolvedValue([burnRateItem]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/burn-rate',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].avgUnitPrice).toBeNull();
expect(body.data[0].projectedDailyCost).toBeNull();
expect(body.data[0].projectedMonthlyCost).toBeNull();
expect(body.data[0].projectedYearlyCost).toBeNull();
expect(body.data[0].currency).toBeNull();
});
});
describe('GET /api/v1/households/:householdId/regimens/:id', () => {
it('returns single regimen by id', async () => {
mockGetById.mockResolvedValue(makeFakeRegimen());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.name).toBe('Daily Medications');
expect(body._id).toBe('reg-1');
});
it('passes id and householdId to service', async () => {
mockGetById.mockResolvedValue(makeFakeRegimen());
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
});
expect(mockGetById).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1');
});
});
describe('POST /api/v1/households/:householdId/regimens', () => {
it('creates regimen and returns 201', async () => {
mockCreate.mockResolvedValue(makeFakeRegimen());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
payload: validPostBody,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('Daily Medications');
expect(body._id).toBe('reg-1');
});
it('returns 400 on invalid body with empty medications array', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
payload: { name: 'Bad Regimen', isActive: true, medications: [] },
});
expect(res.statusCode).toBe(400);
});
it('returns 400 on invalid body with missing name', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
payload: {
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
},
],
},
});
expect(res.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/households/:householdId/regimens/:id', () => {
it('updates regimen and returns 200', async () => {
mockUpdate.mockResolvedValue(makeFakeRegimen({ name: 'Updated Regimen' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
payload: { name: 'Updated Regimen' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated Regimen');
});
it('passes id, householdId, and body to service', async () => {
mockUpdate.mockResolvedValue(makeFakeRegimen({ isActive: false }));
await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
payload: { isActive: false },
});
expect(mockUpdate).toHaveBeenCalledWith(
'reg-1',
'hh1',
'kc-1',
expect.objectContaining({ isActive: false }),
);
});
});
describe('DELETE /api/v1/households/:householdId/regimens/:id', () => {
it('deletes regimen and returns 204', async () => {
mockDelete.mockResolvedValue(undefined);
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
expect(mockDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1');
});
});
});

View file

@ -0,0 +1,857 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RegimensService } from '../../../src/modules/regimens/regimens.service.js';
import { DosageFrequency } from '@meshitrack/shared';
describe(RegimensService.name, () => {
const mockRegimensRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findActiveByUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockMedicinesRepo = {
findById: vi.fn(),
findByHousehold: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockCabinetRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
getAggregateSummary: vi.fn(),
create: vi.fn(),
update: vi.fn(),
adjustQuantity: vi.fn(),
findExpiringSoon: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
discard: vi.fn(),
findActiveByMedicineForFEFO: vi.fn(),
};
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: RegimensService;
beforeEach(() => {
vi.clearAllMocks();
service = new RegimensService({
regimensRepository: mockRegimensRepo as never,
medicinesRepository: mockMedicinesRepo as never,
cabinetRepository: mockCabinetRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockRegimensRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', 'user-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockRegimensRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
});
});
describe('getById', () => {
it('returns regimen when found', async () => {
const regimen = { _id: 'reg-1', name: 'Morning' };
mockRegimensRepo.findById.mockResolvedValue(regimen);
const result = await service.getById('reg-1', 'hh1', 'user-1');
expect(result).toEqual(regimen);
expect(mockRegimensRepo.findById).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1');
});
it('throws NotFoundError when not found', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
'Regimen not found',
);
});
});
describe('create', () => {
const createInput = {
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
],
};
it('creates regimen with denormalized medications', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
const created = {
_id: 'reg-1',
...createInput,
medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }],
};
mockRegimensRepo.create.mockResolvedValue(created);
const result = await service.create(createInput, 'hh1', 'user-1');
expect(result).toEqual(created);
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Morning Routine',
isActive: true,
medications: expect.arrayContaining([
expect.objectContaining({
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
dosage: 1,
dosageUnit: 'tablet',
frequency: DosageFrequency.DAILY,
}),
]),
}),
'hh1',
'user-1',
'user-1',
);
});
it('preserves optional medication fields', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' });
await service.create(
{
name: 'Morning',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 2,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 4,
timeOfDay: 'morning' as never,
instructions: 'Take with food',
},
],
},
'hh1',
'user-1',
);
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medications: expect.arrayContaining([
expect.objectContaining({
customFrequencyPerDay: 4,
timeOfDay: 'morning',
instructions: 'Take with food',
}),
]),
}),
'hh1',
'user-1',
'user-1',
);
});
it('throws NotFoundError when medicine not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.create(createInput, 'hh1', 'user-1')).rejects.toThrow(
'Medicine not found: med-1',
);
});
it('denormalizes multiple medications', async () => {
mockMedicinesRepo.findById
.mockResolvedValueOnce({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
})
.mockResolvedValueOnce({
_id: 'med-2',
name: 'Aspirin',
strength: 100,
strengthUnit: 'mg',
form: 'tablet',
});
mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' });
await service.create(
{
name: 'Full Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
'hh1',
'user-1',
);
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medications: expect.arrayContaining([
expect.objectContaining({ medicineId: 'med-1', medicineName: 'Metformin' }),
expect.objectContaining({ medicineId: 'med-2', medicineName: 'Aspirin' }),
]),
}),
'hh1',
'user-1',
'user-1',
);
});
});
describe('update', () => {
it('updates name only', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
const updated = { _id: 'reg-1', name: 'Evening' };
mockRegimensRepo.update.mockResolvedValue(updated);
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
expect(result).toEqual(updated);
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
name: 'Evening',
});
});
it('updates isActive only', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', isActive: true });
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1', isActive: false });
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
isActive: false,
});
});
it('updates medications with denormalization', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' });
await service.update('reg-1', 'hh1', 'user-1', {
medications: [
{
medicineId: 'med-1',
dosage: 2,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
],
});
expect(mockRegimensRepo.update).toHaveBeenCalledWith(
'reg-1',
'hh1',
'user-1',
expect.objectContaining({
medications: expect.arrayContaining([
expect.objectContaining({ medicineName: 'Metformin' }),
]),
}),
);
});
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(
service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' }),
).rejects.toThrow('Regimen not found');
});
it('throws NotFoundError when update returns null', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
mockRegimensRepo.update.mockResolvedValue(null);
await expect(service.update('reg-1', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
'Regimen not found',
);
});
it('skips undefined fields in updateData', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' });
await service.update('reg-1', 'hh1', 'user-1', {});
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {});
});
it('throws NotFoundError when medicine in medications not found', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(
service.update('reg-1', 'hh1', 'user-1', {
medications: [
{
medicineId: 'med-missing',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
],
}),
).rejects.toThrow('Medicine not found: med-missing');
});
});
describe('delete', () => {
it('soft deletes regimen', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockRegimensRepo.softDelete.mockResolvedValue({ _id: 'reg-1', isDeleted: true });
const result = await service.delete('reg-1', 'hh1', 'user-1');
expect(result.isDeleted).toBe(true);
expect(mockRegimensRepo.softDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1');
});
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
'Regimen not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockRegimensRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('reg-1', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
});
});
describe('getActiveByUser', () => {
it('delegates to repository', async () => {
const regimens = [{ _id: 'reg-1', isActive: true }];
mockRegimensRepo.findActiveByUser.mockResolvedValue(regimens);
const result = await service.getActiveByUser('hh1', 'user-1');
expect(result).toEqual(regimens);
expect(mockRegimensRepo.findActiveByUser).toHaveBeenCalledWith('hh1', 'user-1');
});
});
describe('calculateBurnRates', () => {
it('returns empty array when no active regimens', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('returns empty array when regimens have no medications', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([{ _id: 'reg-1', medications: [] }]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('calculates burn rates for single medicine', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 30, earliestExpiry: new Date('2026-06-01T00:00:00.000Z') },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([['med-1', { avgUnitPrice: 0.5, currency: 'USD' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].medicineName).toBe('Metformin');
expect(result[0].dailyConsumption).toBe(1);
expect(result[0].totalInCabinet).toBe(30);
expect(result[0].daysUntilEmpty).toBe(30);
expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z');
expect(result[0].avgUnitPrice).toBe(0.5);
expect(result[0].projectedDailyCost).toBe(0.5);
expect(result[0].projectedMonthlyCost).toBe(15);
expect(result[0].projectedYearlyCost).toBe(182.5);
expect(result[0].currency).toBe('USD');
});
it('sums daily consumption across multiple regimens', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
{
_id: 'reg-2',
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 90, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 1*1 + 1*2 = 3 daily
expect(result[0].dailyConsumption).toBe(3);
expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30
expect(result[0].earliestExpiry).toBeNull();
expect(result[0].avgUnitPrice).toBeNull();
expect(result[0].projectedDailyCost).toBeNull();
expect(result[0].projectedMonthlyCost).toBeNull();
expect(result[0].projectedYearlyCost).toBeNull();
expect(result[0].currency).toBeNull();
});
it('handles medicine not in cabinet stock', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result[0].totalInCabinet).toBe(0);
expect(result[0].daysUntilEmpty).toBe(0); // Math.floor(0/1) = 0
});
it('excludes AS_NEEDED frequency from burn rate results', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Ibuprofen',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('handles CUSTOM frequency with customFrequencyPerDay', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Custom Med',
dosage: 2,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 3,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 60, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 2 * 3 = 6 daily
expect(result[0].dailyConsumption).toBe(6);
expect(result[0].daysUntilEmpty).toBe(10); // 60 / 6 = 10
});
it('excludes CUSTOM frequency without customFrequencyPerDay (zero consumption)', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Custom Med',
dosage: 2,
frequency: DosageFrequency.CUSTOM,
// no customFrequencyPerDay -> 0 daily consumption -> excluded
},
],
},
]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('sorts by daysUntilEmpty ascending (most urgent first, nulls last)', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-3',
medicineName: 'Med C',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 30, earliestExpiry: null },
{ _id: 'med-2', totalQuantity: 10, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// AS_NEEDED (med-3) excluded; med-2: 10 days, med-1: 30 days
expect(result).toHaveLength(2);
expect(result[0].medicineId).toBe('med-2');
expect(result[0].daysUntilEmpty).toBe(10);
expect(result[1].medicineId).toBe('med-1');
expect(result[1].daysUntilEmpty).toBe(30);
});
it('returns empty array when all medications are AS_NEEDED', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('excludes AS_NEEDED from results even when mixed with scheduled frequencies', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-3',
medicineName: 'Med C',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-2', totalQuantity: 10, earliestExpiry: null },
{ _id: 'med-3', totalQuantity: 30, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// AS_NEEDED (med-1) excluded; med-2: 10 days, med-3: 30 days
expect(result).toHaveLength(2);
expect(result[0].medicineId).toBe('med-2');
expect(result[0].daysUntilEmpty).toBe(10);
expect(result[1].medicineId).toBe('med-3');
expect(result[1].daysUntilEmpty).toBe(30);
});
it('handles WEEKLY frequency', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Weekly Med',
dosage: 1,
frequency: DosageFrequency.WEEKLY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 4, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 1 * (1/7) ~= 0.1429 daily
expect(result[0].dailyConsumption).toBeCloseTo(1 / 7);
expect(result[0].daysUntilEmpty).toBe(28); // Math.floor(4 / (1/7)) = 28
});
it('handles EVERY_OTHER_DAY frequency', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'EOD Med',
dosage: 1,
frequency: DosageFrequency.EVERY_OTHER_DAY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 15, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 1 * 0.5 = 0.5 daily
expect(result[0].dailyConsumption).toBe(0.5);
expect(result[0].daysUntilEmpty).toBe(30); // Math.floor(15 / 0.5) = 30
});
it('handles THREE_TIMES_DAILY frequency', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'TID Med',
dosage: 1,
frequency: DosageFrequency.THREE_TIMES_DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 90, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result[0].dailyConsumption).toBe(3);
expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30
});
it('calculates projected costs correctly', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Expensive Med',
dosage: 2,
frequency: DosageFrequency.DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 60, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([['med-1', { avgUnitPrice: 1.5, currency: 'EUR' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');
// dailyConsumption = 2, avgUnitPrice = 1.5
expect(result[0].projectedDailyCost).toBe(3); // 1.5 * 2
expect(result[0].projectedMonthlyCost).toBe(90); // 3 * 30
expect(result[0].projectedYearlyCost).toBe(1095); // 3 * 365
expect(result[0].currency).toBe('EUR');
});
it('handles multiple medicines with different stock and price data', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 10, earliestExpiry: new Date('2026-03-01T00:00:00.000Z') },
{ _id: 'med-2', totalQuantity: 60, earliestExpiry: new Date('2026-12-01T00:00:00.000Z') },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([['med-1', { avgUnitPrice: 2.0, currency: 'USD' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');
// med-1: 10 days until empty, med-2: 30 days
expect(result[0].medicineId).toBe('med-1');
expect(result[0].daysUntilEmpty).toBe(10);
expect(result[0].avgUnitPrice).toBe(2.0);
expect(result[1].medicineId).toBe('med-2');
expect(result[1].daysUntilEmpty).toBe(30);
expect(result[1].avgUnitPrice).toBeNull();
expect(result[1].currency).toBeNull();
});
});
});

View file

@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShoppingListsRepository } from '../../../src/modules/shopping-lists/shopping-lists.repository.js';
const { mockSave, MockShoppingListModel } = 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, MockShoppingListModel: MockModel };
});
vi.mock('../../../src/schemas/shopping-list.schema.js', () => ({
ShoppingListModel: MockShoppingListModel,
}));
const { ShoppingListModel } = await import('../../../src/schemas/shopping-list.schema.js');
function makeChain(result: unknown = null) {
return {
sort: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(result),
};
}
describe(ShoppingListsRepository.name, () => {
let repo: ShoppingListsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new ShoppingListsRepository();
});
describe('create', () => {
it('saves new shopping list model and returns simple object', async () => {
const doc = { _id: 'list1', name: 'Test List' };
mockSave.mockResolvedValue({ toObject: () => doc });
const result = await repo.create({ name: 'Test List', householdId: 'h1', createdBy: 'u1', status: 'active' });
expect(mockSave).toHaveBeenCalled();
expect(result._id).toBe('list1');
});
});
describe('list', () => {
it('queries lists for household ordered newest first', async () => {
const chain = makeChain([{ _id: 'list1' }]);
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
await repo.list('h1');
expect(ShoppingListModel.find).toHaveBeenCalledWith({ householdId: 'h1' });
expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 });
});
});
describe('findById', () => {
it('queries distinct document by ID and householdId', async () => {
const chain = makeChain({ _id: 'list1' });
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
const result = await repo.findById('list1', 'h1');
expect(ShoppingListModel.findOne).toHaveBeenCalledWith({ _id: 'list1', householdId: 'h1' });
expect(result?._id).toBe('list1');
});
});
describe('findActiveByHousehold', () => {
it('queries specifically active/shopping lists sorted by update recency', async () => {
const chain = makeChain([{ _id: 'list1' }]);
vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any);
await repo.findActiveByHousehold('h1');
expect(ShoppingListModel.find).toHaveBeenCalledWith({
householdId: 'h1',
status: { $in: ['active', 'shopping'] }
});
expect(chain.sort).toHaveBeenCalledWith({ updatedAt: -1 });
});
});
describe('update', () => {
it('sets top level list variables atomically', async () => {
const chain = makeChain({});
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
await repo.update('list1', 'h1', { name: 'New Name' });
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'list1', householdId: 'h1' },
{ $set: { name: 'New Name' } },
{ new: true }
);
});
});
describe('delete', () => {
it('executes findOneAndDelete targeting target IDs', async () => {
const chain = makeChain({});
vi.mocked(ShoppingListModel.findOneAndDelete).mockReturnValue(chain as any);
await repo.delete('list1', 'h1');
expect(ShoppingListModel.findOneAndDelete).toHaveBeenCalledWith({ _id: 'list1', householdId: 'h1' });
});
});
// --- Atomic Subdocument Array Actions Tests ---
describe('addItem', () => {
it('executes $push operator targeting list subdocuments', async () => {
const chain = makeChain({});
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
const mockItem = { id: 'itm1', quantity: 1, unit: 'g', checked: false, addedToPantry: false };
await repo.addItem('list1', 'h1', mockItem as any);
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'list1', householdId: 'h1' },
{ $push: { items: mockItem } },
{ new: true }
);
});
});
describe('updateItem', () => {
it('maps partial payload to flattened positional $ keys', async () => {
const chain = makeChain({});
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
await repo.updateItem('list1', 'h1', 'itm1', { checked: true, actualPrice: 5.5 });
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'list1', householdId: 'h1', 'items.id': 'itm1' },
{
$set: {
'items.$.checked': true,
'items.$.actualPrice': 5.5
}
},
{ new: true }
);
});
});
describe('removeItem', () => {
it('executes $pull operator matching inner item tracking id', async () => {
const chain = makeChain({});
vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any);
await repo.removeItem('list1', 'h1', 'itm1');
expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'list1', householdId: 'h1' },
{ $pull: { items: { id: 'itm1' } } },
{ new: true }
);
});
});
describe('sortItems logic', () => {
it('sorts items by checked status, category, name, and id', async () => {
const items = [
{ id: '6', customName: 'Zebra', category: 'Animal', checked: true },
{ id: '1', customName: 'Apple', category: 'Fruit', checked: false },
{ id: '2', customName: 'Banana', category: 'Fruit', checked: false },
{ id: '3', customName: 'Aardvark', category: 'Animal', checked: false },
{ id: '5', customName: 'Apple', category: 'Fruit', checked: true },
{ id: '4', customName: 'Bread', checked: false }, // No category (should come first in category sort)
{ id: '0', checked: false }, // No name, no category (should come first in all)
];
const chain = makeChain({ _id: 'list1', items });
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
const result = await repo.findById('list1', 'h1');
// Expected order:
// 1. Unchecked, No category, No name (0)
// 2. Unchecked, No category, Bread (4)
// 3. Unchecked, Animal, Aardvark (3)
// 4. Unchecked, Fruit, Apple (1)
// 5. Unchecked, Fruit, Banana (2)
// 6. Checked, Animal, Zebra (6)
// 7. Checked, Fruit, Apple (5)
expect(result.items[0].id).toBe('0');
expect(result.items[1].id).toBe('4');
expect(result.items[2].id).toBe('3');
expect(result.items[3].id).toBe('1');
expect(result.items[4].id).toBe('2');
expect(result.items[5].id).toBe('6');
expect(result.items[6].id).toBe('5');
});
it('handles mixed missing/present categories and names during sorting', async () => {
const items = [
{ id: '1', customName: 'Apple', checked: false }, // category missing
{ id: '2', category: 'Fruit', checked: false }, // customName missing
];
const chain = makeChain({ _id: 'list1', items });
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
const result = await repo.findById('list1', 'h1');
// Expected order:
// 1. (id:1) - empty category comes before 'Fruit'
// 2. (id:2) - 'Fruit' category
expect(result.items[0].id).toBe('1');
expect(result.items[1].id).toBe('2');
});
it('sorts items by id as a final tie-breaker', async () => {
const items = [
{ id: 'B', customName: 'Apple', category: 'Fruit', checked: false },
{ id: 'A', customName: 'Apple', category: 'Fruit', checked: false },
];
const chain = makeChain({ _id: 'list1', items });
vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any);
const result = await repo.findById('list1', 'h1');
expect(result.items[0].id).toBe('A');
expect(result.items[1].id).toBe('B');
});
it('handles null items or list gracefully', () => {
expect((repo as any).sortItems(null)).toBeNull();
expect((repo as any).sortItems({ name: 'foo' })).toEqual({ name: 'foo' });
});
});
});

View file

@ -0,0 +1,325 @@
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 mockList = vi.fn();
const mockFindById = vi.fn();
const mockCreate = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockAddItem = vi.fn();
const mockUpdateItem = vi.fn();
const mockRemoveItem = vi.fn();
vi.mock('../../../src/modules/shopping-lists/shopping-lists.repository.js', () => ({
ShoppingListsRepository: class {
list = mockList;
findById = mockFindById;
create = mockCreate;
update = mockUpdate;
delete = mockDelete;
addItem = mockAddItem;
updateItem = mockUpdateItem;
removeItem = mockRemoveItem;
},
}));
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/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 shoppingListsRoutes from '../../../src/modules/shopping-lists/shopping-lists.routes.js';
describe('shopping-lists.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(shoppingListsRoutes);
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 makeShoppingList(overrides = {}) {
return {
_id: 'list1',
householdId: 'hh1',
name: 'Weekly Checklist',
items: [],
status: 'active',
createdBy: 'kc-1',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe('GET /api/v1/households/:householdId/shopping-lists', () => {
it('returns all lists belonging to household', async () => {
mockList.mockResolvedValue([makeShoppingList()]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/shopping-lists',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json()).toHaveLength(1);
});
});
describe('POST /api/v1/households/:householdId/shopping-lists', () => {
it('persists metadata and returns 201 response', async () => {
mockCreate.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newListA', createdAt: new Date(), updatedAt: new Date() }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/shopping-lists',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Costco Run',
items: []
}),
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Costco Run');
});
});
describe('POST /api/v1/households/:householdId/shopping-lists/:id/items', () => {
it('adds new checklist subdocument item generating tracking UUIDs', async () => {
mockFindById.mockResolvedValue(makeShoppingList());
const updated = makeShoppingList({
items: [{ id: 'itemuuid123', productId: 'p1', quantity: 1, unit: 'piece', checked: false, addedToPantry: false }]
});
mockAddItem.mockResolvedValue(updated);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/shopping-lists/list1/items',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
productId: 'p1',
quantity: 1,
unit: 'piece'
}),
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.items).toHaveLength(1);
expect(body.items[0].productId).toBe('p1');
});
});
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());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/shopping-lists/list1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json()._id).toBe('list1');
});
});
describe('PATCH /api/v1/households/:householdId/shopping-lists/:id', () => {
it('updates list metadata', async () => {
mockFindById.mockResolvedValue(makeShoppingList());
mockUpdate.mockResolvedValue(makeShoppingList({ name: 'Updated Name' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/shopping-lists/list1',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Updated Name' }),
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated Name');
});
});
describe('DELETE /api/v1/households/:householdId/shopping-lists/:id', () => {
it('removes list', async () => {
mockFindById.mockResolvedValue(makeShoppingList());
mockDelete.mockResolvedValue(true);
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/shopping-lists/list1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
describe('PATCH /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => {
it('updates item inline and broadcasts differential updates', async () => {
const item = { id: 'itemA', productId: 'p1', quantity: 2, unit: 'piece', checked: false };
mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [{ ...item, checked: true }] }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({ checked: true }),
});
expect(res.statusCode).toBe(200);
expect(res.json().items[0].checked).toBe(true);
});
it('skips broadcast if item is missing from updated list', async () => {
// Return a list where itemA is gone (maybe someone else deleted it)
mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [] }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
headers: { ...authHeaders, 'content-type': 'application/json' },
body: JSON.stringify({ checked: true }),
});
expect(res.statusCode).toBe(200);
expect(res.json().items).toHaveLength(0);
});
});
describe('DELETE /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => {
it('deletes an item from the checklist', async () => {
mockRemoveItem.mockResolvedValue(makeShoppingList({ items: [] }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
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();
});
});
});

View file

@ -0,0 +1,492 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShoppingListsService } from '../../../src/modules/shopping-lists/shopping-lists.service.js';
import { NotFoundError } from '../../../src/common/errors.js';
describe('ShoppingListsService', () => {
let service: ShoppingListsService;
const mockListsRepo = {
list: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
addItem: vi.fn(),
updateItem: vi.fn(),
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);
mockListsRepo.create.mockImplementation(arg => arg);
const result = await service.create(
{
name: 'Weekly run',
items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
},
'hh1',
'u1'
);
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBeDefined();
expect(result.items[0].estimatedPrice).toBe(5);
expect(result.totalEstimatedCost).toBe(5);
});
it('handles missing items and retains explicit categories without hitting product info', 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');
});
});
describe('list', () => {
it('delegates to repository', async () => {
mockListsRepo.list.mockResolvedValue(['listA']);
const res = await service.list('hh1');
expect(mockListsRepo.list).toHaveBeenCalledWith('hh1');
expect(res).toEqual(['listA']);
});
});
describe('getById', () => {
it('throws NotFoundError if repository returns null', async () => {
mockListsRepo.findById.mockResolvedValue(null);
await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('updates shopping list properties and returns it', async () => {
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
mockListsRepo.update.mockResolvedValue({ _id: 'list1', name: 'New Name' });
const res = await service.update('list1', 'hh1', { name: 'New Name' });
expect(mockListsRepo.update).toHaveBeenCalledWith('list1', 'hh1', { name: 'New Name' });
expect(res.name).toBe('New Name');
});
it('throws NotFoundError if update returns null', async () => {
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
mockListsRepo.update.mockResolvedValue(null);
await expect(service.update('list1', 'hh1', { name: 'New Name' })).rejects.toThrow(NotFoundError);
});
});
describe('addItem', () => {
it('hydrates single product pricing and pushes 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', {
productId: 'prodA',
quantity: 1,
unit: 'g' as any,
});
expect(mockListsRepo.addItem).toHaveBeenCalledWith(
'list1',
'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', () => {
it('injects correct checked timestamps on check-off state mutations', async () => {
mockListsRepo.updateItem.mockResolvedValue({});
await service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'userIdX');
expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
'list1',
'hh1',
'itemA',
expect.objectContaining({
checked: true,
checkedBy: 'userIdX',
checkedAt: expect.any(Date),
})
);
});
it('wipes timestamps if unchecking an item', async () => {
mockListsRepo.updateItem.mockImplementation((a, b, c, d) => Promise.resolve(d));
const res = await service.updateItem('list1', 'hh1', 'itemA', { checked: false }, 'userIdX');
expect(res.checkedAt).toBeUndefined();
expect(res.checkedBy).toBeUndefined();
});
it('throws NotFoundError if item/list is missing on update', async () => {
mockListsRepo.updateItem.mockResolvedValue(null);
await expect(service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'u1')).rejects.toThrow(NotFoundError);
});
it('does not touch timestamps if checked is not provided', async () => {
mockListsRepo.updateItem.mockResolvedValue({});
await service.updateItem('list1', 'hh1', 'itemA', { quantity: 5 } as any, 'u1');
expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
'list1',
'hh1',
'itemA',
{ quantity: 5 }
);
});
});
describe('removeItem', () => {
it('removes item from list repository', async () => {
mockListsRepo.removeItem.mockResolvedValue({ _id: 'list1' });
await service.removeItem('list1', 'hh1', 'itemA');
expect(mockListsRepo.removeItem).toHaveBeenCalledWith('list1', 'hh1', 'itemA');
});
it('throws NotFoundError if list not found on removeItem', async () => {
mockListsRepo.removeItem.mockResolvedValue(null);
await expect(service.removeItem('list1', 'hh1', 'itemA')).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes the shopping list', async () => {
mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
mockListsRepo.delete.mockResolvedValue(true);
await service.delete('list1', 'hh1');
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);
});
});
});

View file

@ -0,0 +1,175 @@
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/store.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;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
}
return { StoreModel: FakeModel };
});
import { StoresRepository } from '../../../src/modules/stores/stores.repository.js';
describe(StoresRepository.name, () => {
let repo: StoresRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new StoresRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'st-1', name: 'Walgreens' }];
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('sets hasMore when more items exist', async () => {
const items = [{ _id: 'st-1' }, { _id: 'st-2' }, { _id: 'st-3' }];
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('handles cursor pagination', async () => {
mockFind.mockResolvedValue([]);
const cursor = Buffer.from('st-1').toString('base64');
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(result.pagination.hasMore).toBe(false);
});
it('filters by tags', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { tags: 'pharmacy,online', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by search', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { search: 'cvs', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('skips tag filter when tags string is empty after trim', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { tags: ' , ', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
});
});
describe('findById', () => {
it('returns store when found', async () => {
const store = { _id: 'st-1', name: 'CVS' };
mockFindOne.mockResolvedValue(store);
const result = await repo.findById('st-1', 'hh1');
expect(result).toEqual(store);
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
expect(await repo.findById('missing', 'hh1')).toBeNull();
});
});
describe('create', () => {
it('creates and returns store', async () => {
const data = { name: 'Walgreens', tags: [], isActive: true };
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data as never, 'hh1', 'user-1');
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('update', () => {
it('updates and returns store', async () => {
const updated = { _id: 'st-1', name: 'Updated' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.update('st-1', 'hh1', { name: 'Updated' });
expect(result).toEqual(updated);
});
it('returns null when not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
expect(await repo.update('missing', 'hh1', {})).toBeNull();
});
});
describe('deactivate', () => {
it('sets isActive=false and returns store', async () => {
const deactivated = { _id: 'st-1', isActive: false };
mockFindOneAndUpdate.mockResolvedValue(deactivated);
const result = await repo.deactivate('st-1', 'hh1');
expect(result).toEqual(deactivated);
});
it('returns null when not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
expect(await repo.deactivate('missing', 'hh1')).toBeNull();
});
});
});

View file

@ -0,0 +1,323 @@
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, mockDeactivate } = vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDeactivate: vi.fn(),
}));
vi.mock('../../../src/modules/stores/stores.repository.js', () => ({
StoresRepository: class {
findByHousehold = vi.fn();
findById = vi.fn();
create = vi.fn();
update = vi.fn();
deactivate = vi.fn();
},
}));
vi.mock('../../../src/modules/stores/stores.service.js', () => ({
StoresService: class {
list = mockList;
getById = mockGetById;
create = mockCreate;
update = mockUpdate;
deactivate = mockDeactivate;
},
}));
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 storesRoutes from '../../../src/modules/stores/stores.routes.js';
function makeFakeStore(overrides = {}) {
return {
_id: 'st-1',
householdId: 'hh1',
name: 'Walgreens',
tags: ['pharmacy'],
isActive: true,
createdBy: 'kc-1',
createdAt: '2024-06-01T00:00:00.000Z',
updatedAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
describe('stores.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(storesRoutes);
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/stores', () => {
it('returns paginated store list', async () => {
mockList.mockResolvedValue({
data: [makeFakeStore()],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/stores',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe('Walgreens');
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/stores?tags=pharmacy&search=cvs&limit=5',
headers: authHeaders,
});
expect(mockList).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ tags: 'pharmacy', search: 'cvs', limit: 5 }),
);
});
it('handles ObjectId and Date in response', async () => {
mockList.mockResolvedValue({
data: [
makeFakeStore({
_id: { toString: () => 'st-obj' },
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
}),
],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/stores',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('st-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
});
it('includes optional fields in response when present', async () => {
mockList.mockResolvedValue({
data: [
makeFakeStore({
address: '123 Main St',
location: { lat: 40.7128, lng: -74.006 },
url: 'https://walgreens.com',
notes: 'Open 24h',
}),
],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/stores',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].address).toBe('123 Main St');
expect(body.data[0].location).toEqual({ lat: 40.7128, lng: -74.006 });
expect(body.data[0].url).toBe('https://walgreens.com');
expect(body.data[0].notes).toBe('Open 24h');
});
});
describe('GET /api/v1/households/:householdId/stores/:id', () => {
it('returns single store', async () => {
mockGetById.mockResolvedValue(makeFakeStore());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/stores/st-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Walgreens');
});
it('passes id and householdId to service', async () => {
mockGetById.mockResolvedValue(makeFakeStore());
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/stores/st-99',
headers: authHeaders,
});
expect(mockGetById).toHaveBeenCalledWith('st-99', 'hh1');
});
});
describe('POST /api/v1/households/:householdId/stores', () => {
it('creates store and returns 201', async () => {
mockCreate.mockResolvedValue(makeFakeStore());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/stores',
headers: authHeaders,
payload: { name: 'Walgreens' },
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Walgreens');
});
it('passes userId to service', async () => {
mockCreate.mockResolvedValue(makeFakeStore());
await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/stores',
headers: authHeaders,
payload: { name: 'CVS' },
});
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ name: 'CVS' }),
'hh1',
'kc-1',
);
});
it('returns 400 for missing name', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/stores',
headers: authHeaders,
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/households/:householdId/stores/:id', () => {
it('updates store and returns 200', async () => {
mockUpdate.mockResolvedValue(makeFakeStore({ name: 'CVS' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/stores/st-1',
headers: authHeaders,
payload: { name: 'CVS' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('CVS');
});
it('passes id, householdId, body to service', async () => {
mockUpdate.mockResolvedValue(makeFakeStore());
await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/stores/st-1',
headers: authHeaders,
payload: { isActive: false },
});
expect(mockUpdate).toHaveBeenCalledWith(
'st-1',
'hh1',
expect.objectContaining({ isActive: false }),
);
});
});
describe('DELETE /api/v1/households/:householdId/stores/:id', () => {
it('deactivates store and returns 200', async () => {
mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/stores/st-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().isActive).toBe(false);
});
it('passes id and householdId to service', async () => {
mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false }));
await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/stores/st-1',
headers: authHeaders,
});
expect(mockDeactivate).toHaveBeenCalledWith('st-1', 'hh1');
});
});
});

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StoresService } from '../../../src/modules/stores/stores.service.js';
describe(StoresService.name, () => {
const mockRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
deactivate: vi.fn(),
};
let service: StoresService;
beforeEach(() => {
vi.clearAllMocks();
service = new StoresService({ storesRepository: mockRepo as never });
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', { limit: 20 });
expect(response).toEqual(result);
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
});
});
describe('getById', () => {
it('returns store when found', async () => {
const store = { _id: 'st-1', name: 'Walgreens' };
mockRepo.findById.mockResolvedValue(store);
expect(await service.getById('st-1', 'hh1')).toEqual(store);
});
it('throws NotFoundError when not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Store not found');
});
});
describe('create', () => {
it('delegates to repository', async () => {
const store = { _id: 'st-1', name: 'CVS' };
mockRepo.create.mockResolvedValue(store);
const result = await service.create(
{ name: 'CVS', tags: [], isActive: true } as never,
'hh1',
'user-1',
);
expect(result).toEqual(store);
expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1');
});
});
describe('update', () => {
it('updates and returns store', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
const updated = { _id: 'st-1', name: 'CVS' };
mockRepo.update.mockResolvedValue(updated);
const result = await service.update('st-1', 'hh1', { name: 'CVS' });
expect(result).toEqual(updated);
});
it('throws NotFoundError on initial lookup failure', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Store not found');
});
it('throws NotFoundError when update returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
mockRepo.update.mockResolvedValue(null);
await expect(service.update('st-1', 'hh1', {})).rejects.toThrow('Store not found');
});
});
describe('deactivate', () => {
it('deactivates and returns store', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
const deactivated = { _id: 'st-1', isActive: false };
mockRepo.deactivate.mockResolvedValue(deactivated);
const result = await service.deactivate('st-1', 'hh1');
expect(result).toEqual(deactivated);
expect(mockRepo.deactivate).toHaveBeenCalledWith('st-1', 'hh1');
});
it('throws NotFoundError when store not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.deactivate('missing', 'hh1')).rejects.toThrow('Store not found');
});
it('throws NotFoundError when deactivate returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
mockRepo.deactivate.mockResolvedValue(null);
await expect(service.deactivate('st-1', 'hh1')).rejects.toThrow('Store not found');
});
});
});

View file

@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Use vi.hoisted so mocks are available in vi.mock factory (which is hoisted)
const { mockLean, mockExec, mockFindOne, mockFindById, mockFindOneAndUpdate, mockSave } =
vi.hoisted(() => {
const mockExec = vi.fn();
const mockLean = vi.fn(() => ({ exec: mockExec }));
return {
mockExec,
mockLean,
mockFindOne: vi.fn(() => ({ lean: mockLean })),
mockFindById: vi.fn(() => ({ lean: mockLean })),
mockFindOneAndUpdate: vi.fn(() => ({ exec: mockExec })),
mockSave: vi.fn(),
};
});
vi.mock('../../../src/schemas/user.schema.js', () => {
class MockUserModel {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
mockSave();
return Promise.resolve(this);
}
toObject() {
return { _id: 'new-id', ...this._data };
}
static findOne = mockFindOne;
static findById = mockFindById;
static findOneAndUpdate = mockFindOneAndUpdate;
}
return { UserModel: MockUserModel };
});
import { UsersRepository } from '../../../src/modules/users/users.repository.js';
describe('UsersRepository', () => {
let repo: UsersRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new UsersRepository();
});
describe('findByKeycloakId', () => {
it('calls findOne with keycloakId and returns lean result', async () => {
const user = { _id: 'u1', keycloakId: 'kc-1' };
mockExec.mockResolvedValue(user);
const result = await repo.findByKeycloakId('kc-1');
expect(mockFindOne).toHaveBeenCalledWith({ keycloakId: 'kc-1' }, null, {
session: undefined,
});
expect(mockLean).toHaveBeenCalled();
expect(mockExec).toHaveBeenCalled();
expect(result).toEqual(user);
});
});
describe('findById', () => {
it('calls findById and returns lean result', async () => {
const user = { _id: 'u1' };
mockExec.mockResolvedValue(user);
const result = await repo.findById('u1');
expect(mockFindById).toHaveBeenCalledWith('u1');
expect(result).toEqual(user);
});
});
describe('create', () => {
it('creates a new user and returns plain object', async () => {
mockSave.mockResolvedValue({});
const data = {
keycloakId: 'kc-1',
email: 'test@example.com',
displayName: 'Test',
householdIds: [],
};
const result = await repo.create(data as never);
expect(mockSave).toHaveBeenCalled();
expect(result).toMatchObject({ keycloakId: 'kc-1' });
});
});
describe('update', () => {
it('calls findOneAndUpdate with $set', async () => {
const updated = { _id: 'u1', displayName: 'Updated' };
mockExec.mockResolvedValue(updated);
const result = await repo.update('kc-1', { displayName: 'Updated' } as never);
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ keycloakId: 'kc-1' },
{ $set: { displayName: 'Updated' } },
{ new: true, lean: true, session: undefined },
);
expect(result).toEqual(updated);
});
});
describe('upsertFromToken', () => {
it('upserts user with $set and $setOnInsert', async () => {
const upserted = { _id: 'u1', keycloakId: 'kc-1' };
mockExec.mockResolvedValue(upserted);
const result = await repo.upsertFromToken('kc-1', 'a@b.com', 'Name');
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ keycloakId: 'kc-1' },
{
$set: { email: 'a@b.com', displayName: 'Name' },
$setOnInsert: { keycloakId: 'kc-1', householdIds: [], defaultHouseholdId: null },
},
{ upsert: true, new: true, lean: true },
);
expect(result).toEqual(upserted);
});
});
});

View file

@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
// Mock jose for auth plugin
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: {},
}),
}));
// Mock the users repository module with a real class
const { mockUpsertFromToken, mockFindByKeycloakId } = vi.hoisted(() => ({
mockUpsertFromToken: vi.fn(),
mockFindByKeycloakId: vi.fn(),
}));
vi.mock('../../../src/modules/users/users.repository.js', () => ({
UsersRepository: class MockUsersRepository {
upsertFromToken = mockUpsertFromToken;
findByKeycloakId = mockFindByKeycloakId;
},
}));
import authPlugin from '../../../src/plugins/auth.plugin.js';
import usersRoutes from '../../../src/modules/users/users.routes.js';
describe('users.routes', () => {
async function buildTestApp() {
const app = Fastify({ logger: false });
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
await app.register(authPlugin);
await app.register(usersRoutes);
return app;
}
beforeEach(() => {
vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
});
it('returns 404 when syncFromToken returns null', async () => {
mockUpsertFromToken.mockResolvedValue(null);
const app = await buildTestApp();
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/users/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(404);
await app.close();
});
it('handles ObjectId and Date objects in response', async () => {
const mockUser = {
_id: { toString: () => 'u-obj' },
keycloakId: 'kc-1',
displayName: 'testuser',
email: 'test@example.com',
householdIds: ['hh1'],
defaultHouseholdId: 'hh1',
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
};
mockUpsertFromToken.mockResolvedValue(mockUser);
const app = await buildTestApp();
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/users/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('u-obj');
expect(body.createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.defaultHouseholdId).toBe('hh1');
await app.close();
});
it('GET /api/v1/users/me syncs user from token and returns profile', async () => {
const mockUser = {
_id: 'u1',
keycloakId: 'kc-1',
displayName: 'testuser',
email: 'test@example.com',
householdIds: ['hh1'],
defaultHouseholdId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockUpsertFromToken.mockResolvedValue(mockUser);
const app = await buildTestApp();
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/users/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.keycloakId).toBe('kc-1');
expect(body.email).toBe('test@example.com');
expect(mockUpsertFromToken).toHaveBeenCalledWith('kc-1', 'test@example.com', 'testuser');
await app.close();
});
});

View file

@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UsersService } from '../../../src/modules/users/users.service.js';
import { NotFoundError } from '../../../src/common/errors.js';
describe('UsersService', () => {
const mockRepo = {
findByKeycloakId: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsertFromToken: vi.fn(),
};
let service: UsersService;
beforeEach(() => {
vi.clearAllMocks();
service = new UsersService({ usersRepository: mockRepo as never });
});
describe('syncFromToken', () => {
it('upserts user from auth token data', async () => {
const authUser = {
keycloakId: 'kc-1',
email: 'test@example.com',
displayName: 'Test User',
roles: ['member'],
householdIds: [],
};
const upserted = { _id: 'u1', ...authUser };
mockRepo.upsertFromToken.mockResolvedValue(upserted);
const result = await service.syncFromToken(authUser);
expect(mockRepo.upsertFromToken).toHaveBeenCalledWith(
'kc-1',
'test@example.com',
'Test User',
);
expect(result).toEqual(upserted);
});
});
describe('getProfile', () => {
it('returns user when found', async () => {
const user = { _id: 'u1', keycloakId: 'kc-1', displayName: 'Test' };
mockRepo.findByKeycloakId.mockResolvedValue(user);
const result = await service.getProfile('kc-1');
expect(result).toEqual(user);
expect(mockRepo.findByKeycloakId).toHaveBeenCalledWith('kc-1');
});
it('throws NotFoundError when user not found', async () => {
mockRepo.findByKeycloakId.mockResolvedValue(null);
await expect(service.getProfile('kc-missing')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { asValue } from 'awilix';
const { MockJOSEError } = vi.hoisted(() => ({
MockJOSEError: class JOSEError extends Error {},
}));
// Mock jose before importing the plugin
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn(),
errors: { JOSEError: MockJOSEError },
}));
const { mockFindByKeycloakId, mockUpsertFromToken } = vi.hoisted(() => ({
mockFindByKeycloakId: vi.fn(),
mockUpsertFromToken: vi.fn(),
}));
import authPlugin from '../../src/plugins/auth.plugin.js';
import * as jose from 'jose';
describe('auth.plugin', () => {
async function buildApp() {
const app = Fastify({ logger: false });
await app.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
app.diContainer.register({
usersRepository: asValue({
findByKeycloakId: mockFindByKeycloakId,
upsertFromToken: mockUpsertFromToken,
}),
});
return app;
}
beforeEach(() => {
vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
});
it('skips auth for routes marked as public', async () => {
const app = await buildApp();
await app.register(authPlugin);
app.get('/public', { config: { public: true } as never }, async () => ({ ok: true }));
await app.ready();
const res = await app.inject({ method: 'GET', url: '/public' });
expect(res.statusCode).toBe(200);
});
it('throws 401 when no Authorization header', async () => {
const app = await buildApp();
await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({ method: 'GET', url: '/protected' });
expect(res.statusCode).toBe(401);
expect(res.json().message).toContain('Missing or invalid Authorization');
});
it('throws 401 when Authorization header is not Bearer', async () => {
const app = await buildApp();
await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Basic abc123' },
});
expect(res.statusCode).toBe(401);
});
it('throws 401 when token is invalid', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValue(new MockJOSEError('Invalid token'));
const app = await buildApp();
await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer invalid-token' },
});
expect(res.statusCode).toBe(401);
expect(res.json().message).toContain('Invalid or expired token');
});
it('sets request.user from valid JWT payload', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
iss: 'http://localhost:8080/realms/meshitrack',
aud: 'meshitrack-api',
},
protectedHeader: { alg: 'RS256' },
key: {} as never,
} as never);
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
const app = await buildApp();
await app.register(authPlugin);
let capturedUser: unknown;
app.get('/protected', async (request) => {
capturedUser = request.user;
return { ok: true };
});
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
expect(capturedUser).toEqual({
keycloakId: 'kc-1',
email: 'test@example.com',
displayName: 'testuser',
roles: ['member'],
householdIds: ['hh1'],
});
});
it('rethrows non-JOSE errors as-is', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValue(new Error('Network failure'));
const app = await buildApp();
await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer some-token' },
});
expect(res.statusCode).toBe(500);
});
it('handles missing optional fields in JWT payload', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {
// sub, email, preferred_username, realm_access all missing
iss: 'http://localhost:8080/realms/meshitrack',
aud: 'meshitrack-api',
},
protectedHeader: { alg: 'RS256' },
key: {} as never,
} as never);
mockFindByKeycloakId.mockResolvedValue(null);
mockUpsertFromToken.mockResolvedValue({ householdIds: [] });
const app = await buildApp();
await app.register(authPlugin);
let capturedUser: unknown;
app.get('/protected', async (request) => {
capturedUser = request.user;
return { ok: true };
});
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer token' },
});
expect(res.statusCode).toBe(200);
expect(capturedUser).toEqual({
keycloakId: '',
email: '',
displayName: '',
roles: [],
householdIds: [],
});
});
});

View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { asValue } from 'awilix';
// Mock jose for the auth plugin dependency
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'] },
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { mockFindByKeycloakId } = vi.hoisted(() => ({
mockFindByKeycloakId: vi.fn(),
}));
import authPlugin from '../../src/plugins/auth.plugin.js';
import householdPlugin from '../../src/plugins/household.plugin.js';
describe('household.plugin', () => {
async function buildApp() {
const app = Fastify({ logger: false });
await app.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
app.diContainer.register({
usersRepository: asValue({
findByKeycloakId: mockFindByKeycloakId,
upsertFromToken: vi.fn().mockResolvedValue({ householdIds: ['hh1'] }),
}),
});
await app.register(authPlugin);
await app.register(householdPlugin);
return app;
}
beforeEach(() => {
vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
});
it('skips household check for public routes', async () => {
const app = await buildApp();
app.get('/public', { config: { public: true } as never }, async () => ({ ok: true }));
await app.ready();
const res = await app.inject({ method: 'GET', url: '/public' });
expect(res.statusCode).toBe(200);
});
it('skips household check for routes with skipHousehold', async () => {
const app = await buildApp();
app.get('/skip', { config: { skipHousehold: true } as never }, async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/skip',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(200);
});
it('throws 403 when user does not belong to household', async () => {
const app = await buildApp();
app.get('/households/:householdId/data', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/households/hh-unknown/data',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(403);
expect(res.json().message).toContain('do not belong');
});
it('sets request.householdId when user belongs to household', async () => {
const app = await buildApp();
let capturedId: string | undefined;
app.get('/households/:householdId/data', async (request) => {
capturedId = request.householdId;
return { ok: true };
});
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/households/hh1/data',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(200);
expect(capturedId).toBe('hh1');
});
it('skips household check when route has no householdId param', async () => {
const app = await buildApp();
app.get('/no-household', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/no-household',
headers: { authorization: 'Bearer valid' },
});
expect(res.statusCode).toBe(200);
});
});

View file

@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
// Mock mongoose and awilix before importing the plugin
vi.mock('mongoose', () => ({
default: {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('@fastify/awilix', () => ({
diContainer: {
register: vi.fn(),
},
}));
import mongoosePlugin from '../../src/plugins/mongoose.plugin.js';
import mongoose from 'mongoose';
import { diContainer } from '@fastify/awilix';
describe('mongoose.plugin', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('connects to MongoDB on registration', async () => {
const app = Fastify({ logger: false });
await app.register(mongoosePlugin);
await app.ready();
expect(mongoose.connect).toHaveBeenCalled();
expect(diContainer.register).toHaveBeenCalled();
await app.close();
});
it('disconnects from MongoDB on close', async () => {
const app = Fastify({ logger: false });
await app.register(mongoosePlugin);
await app.ready();
await app.close();
expect(mongoose.disconnect).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { HouseholdModel } from '../../src/schemas/household.schema.js';
describe('HouseholdModel', () => {
it('is a valid mongoose model', () => {
expect(HouseholdModel.modelName).toBe('Household');
});
it('has expected schema paths', () => {
const paths = Object.keys(HouseholdModel.schema.paths);
expect(paths).toContain('name');
expect(paths).toContain('ownerUserId');
expect(paths).toContain('members');
expect(paths).toContain('inviteCode');
expect(paths).toContain('settings');
expect(paths).toContain('createdAt');
expect(paths).toContain('updatedAt');
});
});

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { MedicineProductModel } from '../../src/schemas/medicine-product.schema.js';
describe(MedicineProductModel.name, () => {
it('is a valid mongoose model', () => {
expect(MedicineProductModel.modelName).toBe('MedicineProduct');
});
it('has expected schema paths', () => {
const paths = Object.keys(MedicineProductModel.schema.paths);
expect(paths).toContain('householdId');
expect(paths).toContain('medicineId');
expect(paths).toContain('medicineName');
expect(paths).toContain('brand');
expect(paths).toContain('packageSize');
expect(paths).toContain('packageUnit');
expect(paths).toContain('source');
expect(paths).toContain('createdBy');
expect(paths).toContain('isDeleted');
expect(paths).toContain('createdAt');
expect(paths).toContain('updatedAt');
});
});

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { MedicineModel } from '../../src/schemas/medicine.schema.js';
describe(MedicineModel.name, () => {
it('is a valid mongoose model', () => {
expect(MedicineModel.modelName).toBe('Medicine');
});
it('has expected schema paths', () => {
const paths = Object.keys(MedicineModel.schema.paths);
expect(paths).toContain('householdId');
expect(paths).toContain('name');
expect(paths).toContain('form');
expect(paths).toContain('strength');
expect(paths).toContain('strengthUnit');
expect(paths).toContain('category');
expect(paths).toContain('tags');
expect(paths).toContain('createdBy');
expect(paths).toContain('isDeleted');
expect(paths).toContain('createdAt');
expect(paths).toContain('updatedAt');
});
});

View file

@ -0,0 +1,32 @@
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');
});
});

View file

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { UserModel } from '../../src/schemas/user.schema.js';
describe('UserModel', () => {
it('is a valid mongoose model', () => {
expect(UserModel.modelName).toBe('User');
});
it('has expected schema paths', () => {
const paths = Object.keys(UserModel.schema.paths);
expect(paths).toContain('keycloakId');
expect(paths).toContain('displayName');
expect(paths).toContain('email');
expect(paths).toContain('householdIds');
expect(paths).toContain('defaultHouseholdId');
expect(paths).toContain('createdAt');
expect(paths).toContain('updatedAt');
});
});