Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

View file

@ -0,0 +1,246 @@
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('../../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 './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,203 @@
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared';
export interface CreateMedicinePriceData {
householdId: string;
medicineProductId: string;
medicineProductBrand: string;
medicineId: string;
medicineName: string;
storeId: string;
storeName: string;
price: number;
currency: string;
quantity: number;
unit: string;
pricePerUnit: number;
date: Date;
isInsurancePrice: boolean;
notes?: string;
createdBy: string;
}
export class MedicinePricesRepository {
public async create(data: CreateMedicinePriceData) {
const record = new MedicinePriceModel(data);
const saved = await record.save();
return saved.toObject();
}
public async findByMedicine(
householdId: string,
medicineId: string,
query: MedicinePriceHistoryQueryInput,
) {
const filter: Record<string, unknown> = { householdId, medicineId };
if (query.storeId) filter['storeId'] = query.storeId;
if (query.startDate || query.endDate) {
const dateFilter: Record<string, Date> = {};
if (query.startDate) dateFilter['$gte'] = new Date(query.startDate);
if (query.endDate) dateFilter['$lte'] = new Date(query.endDate);
filter['date'] = dateFilter;
}
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $lt: id };
}
const limit = query.limit;
const items = await MedicinePriceModel.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async compareStores(householdId: string, medicineId: string) {
// Get the most recent price per store for this medicine
const results = await MedicinePriceModel.aggregate([
{ $match: { householdId, medicineId } },
{ $sort: { storeId: 1, date: -1 } },
{
$group: {
_id: '$storeId',
storeName: { $first: '$storeName' },
latestPrice: { $first: '$price' },
latestPricePerUnit: { $first: '$pricePerUnit' },
currency: { $first: '$currency' },
date: { $first: '$date' },
isInsurancePrice: { $first: '$isInsurancePrice' },
},
},
{ $sort: { latestPricePerUnit: 1 } },
]).exec();
return results.map((r) => ({
storeId: r._id as string,
storeName: r.storeName as string,
latestPrice: r.latestPrice as number,
latestPricePerUnit: r.latestPricePerUnit as number,
currency: r.currency as string,
date: r.date as Date,
isInsurancePrice: r.isInsurancePrice as boolean,
}));
}
public async getLatestForMedicine(
householdId: string,
medicineId: string,
storeId?: string,
) {
const filter: Record<string, unknown> = { householdId, medicineId };
if (storeId) filter['storeId'] = storeId;
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
}
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
const dateFormat =
query.period === 'month' ? '%Y-%m' : query.period === 'quarter' ? '%Y-Q%q' : '%Y';
const [spendingOverTime, topBySpending, spendingByStore] = await Promise.all([
MedicinePriceModel.aggregate([
{ $match: { householdId } },
{
$group: {
_id: { $dateToString: { format: dateFormat, date: '$date' } },
total: { $sum: '$price' },
},
},
{ $sort: { _id: 1 } },
{ $project: { _id: 0, period: '$_id', total: 1 } },
]).exec(),
MedicinePriceModel.aggregate([
{ $match: { householdId } },
{
$group: {
_id: '$medicineId',
medicineName: { $first: '$medicineName' },
totalSpent: { $sum: '$price' },
avgPricePerUnit: { $avg: '$pricePerUnit' },
},
},
{ $sort: { totalSpent: -1 } },
{ $limit: 10 },
{ $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } },
]).exec(),
MedicinePriceModel.aggregate([
{ $match: { householdId } },
{
$group: {
_id: '$storeId',
storeName: { $first: '$storeName' },
totalSpent: { $sum: '$price' },
purchaseCount: { $sum: 1 },
},
},
{ $sort: { totalSpent: -1 } },
{ $project: { _id: 0, storeId: '$_id', storeName: 1, totalSpent: 1, purchaseCount: 1 } },
]).exec(),
]);
// Price alerts: medicines where the most recent price is >10% higher than the previous
const priceAlerts = await MedicinePriceModel.aggregate([
{ $match: { householdId } },
{ $sort: { medicineId: 1, storeId: 1, date: -1 } },
{
$group: {
_id: { medicineId: '$medicineId', storeId: '$storeId' },
medicineName: { $first: '$medicineName' },
storeName: { $first: '$storeName' },
prices: { $push: '$pricePerUnit' },
},
},
{ $match: { 'prices.1': { $exists: true } } },
{
$addFields: {
currentPrice: { $arrayElemAt: ['$prices', 0] },
previousPrice: { $arrayElemAt: ['$prices', 1] },
},
},
{
$addFields: {
changePercent: {
$multiply: [
{ $divide: [{ $subtract: ['$currentPrice', '$previousPrice'] }, '$previousPrice'] },
100,
],
},
},
},
{ $match: { changePercent: { $gt: 10 } } },
{
$project: {
_id: 0,
medicineId: '$_id.medicineId',
medicineName: 1,
storeName: 1,
previousPrice: 1,
currentPrice: 1,
changePercent: 1,
},
},
]).exec();
return {
spendingOverTime: spendingOverTime as { period: string; total: number }[],
topBySpending: topBySpending as { medicineId: string; medicineName: string; totalSpent: number; avgPricePerUnit: number }[],
spendingByStore: spendingByStore as { storeId: string; storeName: string; totalSpent: number; purchaseCount: number }[],
priceAlerts: priceAlerts as { medicineId: string; medicineName: string; storeName: string; previousPrice: number; currentPrice: number; changePercent: number }[],
};
}
}

View file

@ -0,0 +1,338 @@
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('./medicine-prices.repository.js', () => ({
MedicinePricesRepository: class {
create = vi.fn();
findByMedicine = vi.fn();
compareStores = vi.fn();
getLatestForMedicine = vi.fn();
getAnalytics = vi.fn();
},
}));
vi.mock('./medicine-prices.service.js', () => ({
MedicinePricesService: class {
recordPrice = mockRecordPrice;
getPriceHistory = mockGetPriceHistory;
compareStores = mockCompareStores;
getAnalytics = mockGetAnalytics;
},
}));
vi.mock('../medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
findById = vi.fn();
},
}));
vi.mock('../stores/stores.repository.js', () => ({
StoresRepository: class {
findById = vi.fn();
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import medicinePricesRoutes from './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,168 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateMedicinePriceRecordSchema,
MedicinePriceHistoryQuerySchema,
MedicinePriceAnalyticsQuerySchema,
MedicinePriceRecordResponseSchema,
MedicinePriceHistoryResponseSchema,
StoreComparisonResponseSchema,
MedicineSpendingAnalyticsResponseSchema,
} from '@meshitrack/shared';
import { MedicinePricesRepository } from './medicine-prices.repository.js';
import { MedicinePricesService } from './medicine-prices.service.js';
import { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import { StoresRepository } from '../stores/stores.repository.js';
type AnyPriceDoc = {
_id: string | { toString: () => string };
householdId: string;
medicineProductId: string;
medicineProductBrand: string;
medicineId: string;
medicineName: string;
storeId: string;
storeName: string;
price: number;
currency: string;
quantity: number;
unit: string;
pricePerUnit: number;
date: Date | string | { toISOString: () => string };
isInsurancePrice: boolean;
notes?: string;
createdBy: string;
createdAt: Date | string | { toISOString: () => string };
};
function toIso(v: Date | string | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
}
function toPriceRecordResponse(rawDoc: unknown) {
const doc = rawDoc as AnyPriceDoc;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
householdId: doc.householdId,
medicineProductId: doc.medicineProductId,
medicineProductBrand: doc.medicineProductBrand,
medicineId: doc.medicineId,
medicineName: doc.medicineName,
storeId: doc.storeId,
storeName: doc.storeName,
price: doc.price,
currency: doc.currency,
quantity: doc.quantity,
unit: doc.unit,
pricePerUnit: doc.pricePerUnit,
date: toIso(doc.date),
isInsurancePrice: doc.isInsurancePrice,
...(doc.notes != null ? { notes: doc.notes } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
medicinePricesRepository: MedicinePricesRepository;
medicinePricesService: MedicinePricesService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
medicinePricesRepository: asClass(MedicinePricesRepository, { lifetime: Lifetime.SINGLETON }),
medicinePricesService: asClass(MedicinePricesService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/medicine-prices',
schema: {
params: householdParams,
body: CreateMedicinePriceRecordSchema,
response: { 201: MedicinePriceRecordResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const record = await service.recordPrice(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toPriceRecordResponse(record));
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicine-prices/history/:medicineId',
schema: {
params: householdParams.extend({ medicineId: z.string() }),
querystring: MedicinePriceHistoryQuerySchema,
response: { 200: MedicinePriceHistoryResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const result = await service.getPriceHistory(
request.params.householdId,
request.params.medicineId,
request.query,
);
return reply.send({
data: result.data.map(toPriceRecordResponse),
pagination: result.pagination,
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicine-prices/compare/:medicineId',
schema: {
params: householdParams.extend({ medicineId: z.string() }),
response: { 200: StoreComparisonResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const results = await service.compareStores(
request.params.householdId,
request.params.medicineId,
);
return reply.send({
data: results.map((r) => ({
...r,
date: toIso(r.date),
})),
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicine-prices/analytics',
schema: {
params: householdParams,
querystring: MedicinePriceAnalyticsQuerySchema,
response: { 200: MedicineSpendingAnalyticsResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const analytics = await service.getAnalytics(request.params.householdId, request.query);
return reply.send(analytics);
},
});
},
{
name: 'medicine-prices-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicinePricesService } from './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,93 @@
import type { MedicinePricesRepository } from './medicine-prices.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { StoresRepository } from '../stores/stores.repository.js';
import type {
CreateMedicinePriceRecordInput,
MedicinePriceHistoryQueryInput,
MedicinePriceAnalyticsQueryInput,
} from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
medicinePricesRepository: MedicinePricesRepository;
medicineProductsRepository: MedicineProductsRepository;
storesRepository: StoresRepository;
}
export class MedicinePricesService {
private readonly medicinePricesRepository: MedicinePricesRepository;
private readonly medicineProductsRepository: MedicineProductsRepository;
private readonly storesRepository: StoresRepository;
public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) {
this.medicinePricesRepository = medicinePricesRepository;
this.medicineProductsRepository = medicineProductsRepository;
this.storesRepository = storesRepository;
}
public async recordPrice(
data: CreateMedicinePriceRecordInput,
householdId: string,
userId: string,
) {
const product = await this.medicineProductsRepository.findById(
data.medicineProductId,
householdId,
);
if (!product) throw new NotFoundError('Medicine product not found');
const store = await this.storesRepository.findById(data.storeId, householdId);
if (!store) throw new NotFoundError('Store not found');
const pricePerUnit = data.price / data.quantity;
const date = data.date ? new Date(data.date) : new Date();
return this.medicinePricesRepository.create({
householdId,
medicineProductId: data.medicineProductId,
medicineProductBrand: product.brand ?? product.medicineName,
medicineId: data.medicineId,
medicineName: product.medicineName,
storeId: data.storeId,
storeName: store.name,
price: data.price,
currency: data.currency,
quantity: data.quantity,
unit: data.unit,
pricePerUnit,
date,
isInsurancePrice: data.isInsurancePrice,
notes: data.notes,
createdBy: userId,
});
}
public async getPriceHistory(
householdId: string,
medicineId: string,
query: MedicinePriceHistoryQueryInput,
) {
return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query);
}
public async compareStores(householdId: string, medicineId: string) {
return this.medicinePricesRepository.compareStores(householdId, medicineId);
}
public async estimatePrice(
householdId: string,
medicineId: string,
storeId?: string,
): Promise<number | null> {
const record = await this.medicinePricesRepository.getLatestForMedicine(
householdId,
medicineId,
storeId,
);
return record ? (record.pricePerUnit as number) : null;
}
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
return this.medicinePricesRepository.getAnalytics(householdId, query);
}
}

View file

@ -177,6 +177,20 @@ describe('medicine-products.routes', () => {
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', () => {

View file

@ -135,6 +135,30 @@ describe(MedicinesService.name, () => {
);
});
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' });

View file

@ -0,0 +1,247 @@
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('../../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 './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('../../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('../../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('../../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('../../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('../../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('../../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,142 @@
import { PurchaseModel } from '../../schemas/purchase.schema.js';
import type { PurchaseQueryInput, UpdatePurchaseInput } from '@meshitrack/shared';
export interface CreatePurchaseItemData {
medicineProductId?: string;
medicineId?: string;
foodProductId?: string;
name: string;
quantity: number;
unit: string;
actualPrice?: number;
currency?: string;
priceRecordId?: string;
addedToCabinet?: boolean;
}
export interface CreatePurchaseData {
householdId: string;
storeId: string;
storeName: string;
status: string;
items: CreatePurchaseItemData[];
notes?: string;
purchasedAt: Date;
createdBy: string;
}
export class PurchasesRepository {
public async create(data: CreatePurchaseData) {
const purchase = new PurchaseModel(data);
const saved = await purchase.save();
return saved.toObject();
}
public async findByHousehold(householdId: string, query: PurchaseQueryInput) {
const filter: Record<string, unknown> = { householdId, isDeleted: false };
if (query.status) filter['status'] = query.status;
if (query.storeId) filter['storeId'] = query.storeId;
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $lt: id };
}
const limit = query.limit;
const items = await PurchaseModel.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
: null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string) {
return PurchaseModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
}
public async update(id: string, householdId: string, data: UpdatePurchaseInput) {
const updateSet: Record<string, unknown> = {};
if (data.notes !== undefined) updateSet['notes'] = data.notes;
if (data.items !== undefined) updateSet['items'] = data.items;
return PurchaseModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: updateSet },
{ new: true, lean: true },
).exec();
}
public async markItemsAddedToCabinet(
purchaseId: string,
householdId: string,
itemIndices: number[],
) {
// Build update using positional array filters
const updateSet: Record<string, unknown> = {};
for (const idx of itemIndices) {
updateSet[`items.${idx}.addedToCabinet`] = true;
}
return PurchaseModel.findOneAndUpdate(
{ _id: purchaseId, householdId, isDeleted: false },
{ $set: updateSet },
{ new: true, lean: true },
).exec();
}
public async receiveAll(purchaseId: string, householdId: string) {
return PurchaseModel.findOneAndUpdate(
{ _id: purchaseId, householdId, isDeleted: false },
{
$set: {
status: 'in_cabinet',
receivedAt: new Date(),
'items.$[].addedToCabinet': true,
},
},
{ new: true, lean: true },
).exec();
}
public async getPendingMedicineStock(
householdId: string,
): Promise<{ medicineId: string; totalUnits: number }[]> {
const results = await PurchaseModel.aggregate([
{ $match: { householdId, status: 'ordered', isDeleted: false } },
{ $unwind: '$items' },
{
$match: {
'items.medicineId': { $exists: true, $ne: null },
'items.addedToCabinet': false,
},
},
{
$group: {
_id: '$items.medicineId',
totalUnits: { $sum: '$items.quantity' },
},
},
{ $project: { _id: 0, medicineId: '$_id', totalUnits: 1 } },
]).exec();
return results as { medicineId: string; totalUnits: number }[];
}
public async softDelete(id: string, householdId: string) {
return PurchaseModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false, status: 'ordered' },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,476 @@
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('./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('./purchases.service.js', () => ({
PurchasesService: class {
list = mockList;
getById = mockGetById;
create = mockCreate;
update = mockUpdate;
receive = mockReceive;
delete = mockDelete;
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import purchasesRoutes from './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,214 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreatePurchaseSchema,
UpdatePurchaseSchema,
PurchaseQuerySchema,
PurchaseResponseSchema,
PurchaseListResponseSchema,
} from '@meshitrack/shared';
import { PurchasesRepository } from './purchases.repository.js';
import { PurchasesService } from './purchases.service.js';
function toIso(v: Date | string | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
}
type AnyPurchaseItem = {
_id?: string | { toString: () => string };
medicineProductId?: string;
medicineId?: string;
foodProductId?: string;
name: string;
quantity: number;
unit: string;
actualPrice?: number;
currency?: string;
priceRecordId?: string;
addedToCabinet: boolean;
};
type AnyPurchase = {
_id: string | { toString: () => string };
householdId: string;
storeId: string;
storeName: string;
status: string;
items: AnyPurchaseItem[];
notes?: string;
purchasedAt: Date | string | { toISOString: () => string };
receivedAt?: Date | string | { toISOString: () => string };
createdBy: string;
createdAt: Date | string | { toISOString: () => string };
updatedAt: Date | string | { toISOString: () => string };
};
function toItemResponse(item: AnyPurchaseItem) {
return {
_id: item._id
? typeof item._id === 'string'
? item._id
: item._id.toString()
: '',
...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}),
...(item.medicineId ? { medicineId: item.medicineId } : {}),
...(item.foodProductId ? { foodProductId: item.foodProductId } : {}),
name: item.name,
quantity: item.quantity,
unit: item.unit,
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
...(item.currency ? { currency: item.currency } : {}),
...(item.priceRecordId ? { priceRecordId: item.priceRecordId } : {}),
addedToCabinet: item.addedToCabinet,
};
}
function toPurchaseResponse(doc: AnyPurchase) {
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
householdId: doc.householdId,
storeId: doc.storeId,
storeName: doc.storeName,
status: doc.status as 'ordered' | 'in_cabinet',
items: doc.items.map(toItemResponse),
...(doc.notes ? { notes: doc.notes } : {}),
purchasedAt: toIso(doc.purchasedAt),
...(doc.receivedAt ? { receivedAt: toIso(doc.receivedAt) } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
purchasesRepository: PurchasesRepository;
purchasesService: PurchasesService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
purchasesRepository: asClass(PurchasesRepository, { lifetime: Lifetime.SINGLETON }),
purchasesService: asClass(PurchasesService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/purchases',
schema: {
params: householdParams,
querystring: PurchaseQuerySchema,
response: { 200: PurchaseListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('purchasesService');
const result = await service.list(request.params.householdId, request.query);
return reply.send({
data: result.data.map(toPurchaseResponse),
pagination: result.pagination,
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/purchases/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: PurchaseResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('purchasesService');
const purchase = await service.getById(request.params.id, request.params.householdId);
return reply.send(toPurchaseResponse(purchase));
},
});
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/purchases',
schema: {
params: householdParams,
body: CreatePurchaseSchema,
response: { 201: PurchaseResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('purchasesService');
const purchase = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toPurchaseResponse(purchase));
},
});
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/purchases/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdatePurchaseSchema,
response: { 200: PurchaseResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('purchasesService');
const purchase = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toPurchaseResponse(purchase));
},
});
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/purchases/:id/receive',
schema: {
params: householdParams.extend({ id: z.string() }),
response: {
200: z.object({
addedCount: z.number(),
priceRecordsCreated: z.number(),
}),
},
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('purchasesService');
const result = await service.receive(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.send(result);
},
});
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/purchases/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: PurchaseResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('purchasesService');
const purchase = await service.delete(request.params.id, request.params.householdId);
return reply.send(toPurchaseResponse(purchase));
},
});
},
{
name: 'purchases-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,418 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PurchasesService } from './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,272 @@
import type { PurchasesRepository } from './purchases.repository.js';
import type { StoresRepository } from '../stores/stores.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
import type { CabinetService } from '../cabinet/cabinet.service.js';
import type {
CreatePurchaseInput,
UpdatePurchaseInput,
PurchaseQueryInput,
} from '@meshitrack/shared';
import { DosageUnit } from '@meshitrack/shared';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
interface Deps {
purchasesRepository: PurchasesRepository;
cabinetService: CabinetService;
storesRepository: StoresRepository;
medicineProductsRepository: MedicineProductsRepository;
medicinePricesRepository: MedicinePricesRepository;
}
export class PurchasesService {
private readonly purchasesRepository: PurchasesRepository;
private readonly cabinetService: CabinetService;
private readonly storesRepository: StoresRepository;
private readonly medicineProductsRepository: MedicineProductsRepository;
private readonly medicinePricesRepository: MedicinePricesRepository;
public constructor({
purchasesRepository,
cabinetService,
storesRepository,
medicineProductsRepository,
medicinePricesRepository,
}: Deps) {
this.purchasesRepository = purchasesRepository;
this.cabinetService = cabinetService;
this.storesRepository = storesRepository;
this.medicineProductsRepository = medicineProductsRepository;
this.medicinePricesRepository = medicinePricesRepository;
}
public async create(data: CreatePurchaseInput, householdId: string, userId: string) {
const store = await this.storesRepository.findById(data.storeId, householdId);
if (!store) throw new NotFoundError('Store not found');
const purchasedAt = data.purchasedAt ? new Date(data.purchasedAt) : new Date();
const items: Array<{
medicineProductId?: string;
medicineId?: string;
name: string;
quantity: number;
unit: string;
actualPrice?: number;
currency?: string;
priceRecordId?: string;
addedToCabinet: boolean;
}> = [];
for (const item of data.items) {
let resolvedName = item.name;
let resolvedMedicineId: string | undefined;
if (item.medicineProductId) {
const product = await this.medicineProductsRepository.findById(
item.medicineProductId,
householdId,
);
if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
if (!resolvedName || resolvedName === item.name) {
resolvedName = product.brand ?? resolvedName;
}
resolvedMedicineId = product.medicineId as string;
}
items.push({
medicineProductId: item.medicineProductId,
medicineId: resolvedMedicineId,
name: resolvedName,
quantity: item.quantity,
unit: item.unit,
actualPrice: item.actualPrice,
currency: item.currency,
addedToCabinet: false,
});
}
if (data.status === 'in_cabinet') {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.medicineProductId && item.medicineId) {
await this.cabinetService.addItem(
{
medicineId: item.medicineId,
medicineProductId: item.medicineProductId,
quantity: item.quantity,
unit: item.unit as DosageUnit,
unitPrice:
item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
totalPrice: item.actualPrice,
storeId: data.storeId,
purchaseDate: purchasedAt.toISOString(),
},
householdId,
userId,
);
if (item.actualPrice !== undefined) {
const product = await this.medicineProductsRepository.findById(
item.medicineProductId,
householdId,
);
if (product) {
await this.medicinePricesRepository.create({
householdId,
medicineProductId: item.medicineProductId,
medicineProductBrand: (product.brand as string) ?? (product.medicineName as string),
medicineId: item.medicineId,
/* v8 ignore next */
medicineName: (product.medicineName as string) ?? '',
storeId: data.storeId,
storeName: store.name as string,
price: item.actualPrice,
currency: item.currency ?? 'USD',
quantity: item.quantity,
unit: item.unit,
/* v8 ignore next */
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
date: purchasedAt,
isInsurancePrice: false,
createdBy: userId,
});
}
}
items[i] = { ...item, addedToCabinet: true };
}
}
}
return this.purchasesRepository.create({
householdId,
storeId: data.storeId,
storeName: store.name as string,
status: data.status,
items,
notes: data.notes,
purchasedAt,
createdBy: userId,
});
}
public async receive(id: string, householdId: string, userId: string) {
const purchase = await this.purchasesRepository.findById(id, householdId);
if (!purchase) throw new NotFoundError('Purchase not found');
if (purchase.status !== 'ordered') {
throw new BadRequestError('Purchase is not in ordered status');
}
let addedCount = 0;
let priceRecordsCreated = 0;
const itemsToUpdate: number[] = [];
const items = purchase.items as Array<{
medicineProductId?: string;
medicineId?: string;
name: string;
quantity: number;
unit: string;
actualPrice?: number;
currency?: string;
addedToCabinet: boolean;
}>;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.medicineProductId && item.medicineId && !item.addedToCabinet) {
await this.cabinetService.addItem(
{
medicineId: item.medicineId,
medicineProductId: item.medicineProductId,
quantity: item.quantity,
unit: item.unit as DosageUnit,
unitPrice:
item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
totalPrice: item.actualPrice,
storeId: purchase.storeId as string,
purchaseDate: (purchase.purchasedAt as Date).toISOString(),
},
householdId,
userId,
);
addedCount++;
if (item.actualPrice !== undefined) {
const product = await this.medicineProductsRepository.findById(
item.medicineProductId,
householdId,
);
if (product) {
await this.medicinePricesRepository.create({
householdId,
medicineProductId: item.medicineProductId,
medicineProductBrand: (product.brand as string) ?? (product.medicineName as string),
medicineId: item.medicineId,
/* v8 ignore next */
medicineName: (product.medicineName as string) ?? '',
storeId: purchase.storeId as string,
storeName: purchase.storeName as string,
price: item.actualPrice,
currency: item.currency ?? 'USD',
quantity: item.quantity,
unit: item.unit,
/* v8 ignore next */
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
date: purchase.purchasedAt as Date,
isInsurancePrice: false,
createdBy: userId,
});
priceRecordsCreated++;
}
}
itemsToUpdate.push(i);
}
}
await this.purchasesRepository.receiveAll(id, householdId);
return { addedCount, priceRecordsCreated };
}
public async list(householdId: string, query: PurchaseQueryInput) {
return this.purchasesRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const purchase = await this.purchasesRepository.findById(id, householdId);
if (!purchase) throw new NotFoundError('Purchase not found');
return purchase;
}
public async update(id: string, householdId: string, data: UpdatePurchaseInput) {
await this.getById(id, householdId);
const updated = await this.purchasesRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Purchase not found');
return updated;
}
public async delete(id: string, householdId: string) {
const deleted = await this.purchasesRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Purchase not found or cannot be deleted');
return deleted;
}
public async getPendingStockByMedicine(
householdId: string,
): Promise<Map<string, number>> {
const results = await this.purchasesRepository.getPendingMedicineStock(householdId);
const map = new Map<string, number>();
for (const r of results) {
map.set(r.medicineId, r.totalUnits);
}
return map;
}
}

View file

@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
}));
vi.mock('../../schemas/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 './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,104 @@
import { RefillListModel } from '../../schemas/refill-list.schema.js';
import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared';
export interface CreateRefillListData {
householdId: string;
name: string;
status: string;
preferredStoreId?: string;
totalEstimatedCost?: number;
createdBy: string;
items: Array<{
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
estimatedPrice?: number;
storeId?: string;
notes?: string;
}>;
}
export class RefillsRepository {
public async create(data: CreateRefillListData) {
const list = new RefillListModel(data);
const saved = await list.save();
return saved.toObject();
}
public async findByHousehold(householdId: string, query: RefillListQueryInput) {
const filter: Record<string, unknown> = { householdId };
if (query.status) filter['status'] = query.status;
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $lt: id };
}
const limit = query.limit;
const items = await RefillListModel.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
: null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string) {
return RefillListModel.findOne({ _id: id, householdId }).lean().exec();
}
public async update(id: string, householdId: string, data: UpdateRefillListInput) {
const updateSet: Record<string, unknown> = {};
if (data.name !== undefined) updateSet['name'] = data.name;
if (data.status !== undefined) updateSet['status'] = data.status;
if (data.preferredStoreId !== undefined) updateSet['preferredStoreId'] = data.preferredStoreId;
return RefillListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: updateSet },
{ new: true, lean: true },
).exec();
}
public async updateItem(
listId: string,
householdId: string,
itemId: string,
data: UpdateRefillListItemInput & { checkedAt?: Date },
) {
const updateSet: Record<string, unknown> = {};
if (data.checked !== undefined) updateSet['items.$.checked'] = data.checked;
if (data.actualPrice !== undefined) updateSet['items.$.actualPrice'] = data.actualPrice;
if (data.storeId !== undefined) updateSet['items.$.storeId'] = data.storeId;
if (data.notes !== undefined) updateSet['items.$.notes'] = data.notes;
if (data.checkedAt !== undefined) updateSet['items.$.checkedAt'] = data.checkedAt;
return RefillListModel.findOneAndUpdate(
{ _id: listId, householdId, 'items._id': itemId },
{ $set: updateSet },
{ new: true, lean: true },
).exec();
}
public async markItemsAddedToCabinet(listId: string, householdId: string, itemIds: string[]) {
return RefillListModel.findOneAndUpdate(
{ _id: listId, householdId },
{ $set: { 'items.$[elem].addedToCabinet': true } },
{
arrayFilters: [{ 'elem._id': { $in: itemIds } }],
new: true,
lean: true,
},
).exec();
}
}

View file

@ -0,0 +1,506 @@
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('./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('./refills.service.js', () => ({
RefillsService: class {
getAlerts = mockGetAlerts;
createList = mockCreateList;
list = mockList;
getById = mockGetById;
updateList = mockUpdateList;
updateItem = mockUpdateItem;
addToCabinet = mockAddToCabinet;
getStoreComparison = mockGetStoreComparison;
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import refillsRoutes from './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,279 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateRefillListSchema,
UpdateRefillListSchema,
UpdateRefillListItemSchema,
RefillListQuerySchema,
RefillAlertQuerySchema,
RefillAlertResponseSchema,
RefillListResponseSchema,
RefillListListResponseSchema,
AddToCabinetResponseSchema,
StoreComparisonItemSchema,
} from '@meshitrack/shared';
import { RefillsRepository } from './refills.repository.js';
import { RefillsService } from './refills.service.js';
function toIso(v: Date | string | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
}
type AnyItem = {
_id: string | { toString: () => string };
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
estimatedPrice?: number;
actualPrice?: number;
checked: boolean;
checkedAt?: Date | string;
addedToCabinet: boolean;
storeId?: string;
notes?: string;
};
type AnyRefillList = {
_id: string | { toString: () => string };
householdId: string;
name: string;
items: AnyItem[];
status: string;
preferredStoreId?: string;
totalEstimatedCost?: number;
createdBy: string;
createdAt: Date | string | { toISOString: () => string };
updatedAt: Date | string | { toISOString: () => string };
};
function toItemResponse(rawItem: unknown) {
const item = rawItem as AnyItem;
return {
_id: typeof item._id === 'string' ? item._id : item._id.toString(),
medicineId: item.medicineId,
medicineName: item.medicineName,
quantity: item.quantity,
unit: item.unit,
...(item.estimatedPrice != null ? { estimatedPrice: item.estimatedPrice } : {}),
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
checked: item.checked,
...(item.checkedAt != null ? { checkedAt: toIso(item.checkedAt) } : {}),
addedToCabinet: item.addedToCabinet,
...(item.storeId != null ? { storeId: item.storeId } : {}),
...(item.notes != null ? { notes: item.notes } : {}),
};
}
function toListResponse(rawDoc: unknown) {
const doc = rawDoc as AnyRefillList;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
householdId: doc.householdId,
name: doc.name,
items: doc.items.map(toItemResponse),
status: doc.status as never,
...(doc.preferredStoreId != null ? { preferredStoreId: doc.preferredStoreId } : {}),
...(doc.totalEstimatedCost != null ? { totalEstimatedCost: doc.totalEstimatedCost } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
refillsRepository: RefillsRepository;
refillsService: RefillsService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
refillsRepository: asClass(RefillsRepository, { lifetime: Lifetime.SINGLETON }),
refillsService: asClass(RefillsService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/refills/alerts',
schema: {
params: householdParams,
querystring: RefillAlertQuerySchema,
response: { 200: z.object({ data: z.array(RefillAlertResponseSchema) }) },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const alerts = await service.getAlerts(
request.params.householdId,
request.query.userId ?? request.user.keycloakId,
request.query.thresholdDays,
);
return reply.send({
data: alerts.map((a) => ({
...a,
lastKnownPrice: a.lastKnownPrice
? { ...a.lastKnownPrice, date: toIso(a.lastKnownPrice.date) }
: undefined,
cheapestOption: a.cheapestOption
? { ...a.cheapestOption, date: toIso(a.cheapestOption.date) }
: undefined,
})),
});
},
});
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/refills/lists',
schema: {
params: householdParams,
body: CreateRefillListSchema,
response: { 201: RefillListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.createList(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toListResponse(list));
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/refills/lists',
schema: {
params: householdParams,
querystring: RefillListQuerySchema,
response: { 200: RefillListListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const result = await service.list(request.params.householdId, request.query);
return reply.send({
data: result.data.map(toListResponse),
pagination: result.pagination,
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/refills/lists/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: RefillListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.getById(request.params.id, request.params.householdId);
return reply.send(toListResponse(list));
},
});
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/refills/lists/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdateRefillListSchema,
response: { 200: RefillListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.updateList(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toListResponse(list));
},
});
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/refills/lists/:id/items/:itemId',
schema: {
params: householdParams.extend({ id: z.string(), itemId: z.string() }),
body: UpdateRefillListItemSchema,
response: { 200: RefillListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.updateItem(
request.params.id,
request.params.householdId,
request.params.itemId,
request.body,
);
return reply.send(toListResponse(list));
},
});
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/refills/lists/:id/add-to-cabinet',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: AddToCabinetResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const result = await service.addToCabinet(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.send(result);
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/refills/lists/:id/store-comparison',
schema: {
params: householdParams.extend({ id: z.string() }),
response: {
200: z.object({
data: z.array(
z.object({
medicineId: z.string(),
storeOptions: z.array(StoreComparisonItemSchema),
}),
),
}),
},
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const comparisons = await service.getStoreComparison(
request.params.id,
request.params.householdId,
);
return reply.send({
data: comparisons.map((c) => ({
medicineId: c.medicineId,
storeOptions: c.storeOptions.map((opt) => ({
...opt,
date: toIso(opt.date),
})),
})),
});
},
});
},
{
name: 'refills-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,400 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RefillsService } from './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,285 @@
import type { RefillsRepository } from './refills.repository.js';
import type { RegimensService } from '../regimens/regimens.service.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetService } from '../cabinet/cabinet.service.js';
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
import type { PurchasesRepository } from '../purchases/purchases.repository.js';
import type {
CreateRefillListInput,
UpdateRefillListInput,
UpdateRefillListItemInput,
RefillListQueryInput,
RefillAlertQueryInput,
} from '@meshitrack/shared';
import { RefillListStatus } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
refillsRepository: RefillsRepository;
regimensService: RegimensService;
cabinetRepository: CabinetRepository;
cabinetService: CabinetService;
medicinePricesRepository: MedicinePricesRepository;
purchasesRepository: PurchasesRepository;
}
export class RefillsService {
private readonly refillsRepository: RefillsRepository;
private readonly regimensService: RegimensService;
private readonly cabinetRepository: CabinetRepository;
private readonly cabinetService: CabinetService;
private readonly medicinePricesRepository: MedicinePricesRepository;
private readonly purchasesRepository: PurchasesRepository;
public constructor({
refillsRepository,
regimensService,
cabinetRepository,
cabinetService,
medicinePricesRepository,
purchasesRepository,
}: Deps) {
this.refillsRepository = refillsRepository;
this.regimensService = regimensService;
this.cabinetRepository = cabinetRepository;
this.cabinetService = cabinetService;
this.medicinePricesRepository = medicinePricesRepository;
this.purchasesRepository = purchasesRepository;
}
public async getAlerts(householdId: string, userId: string, thresholdDays = 7) {
const burnRates = await this.regimensService.calculateBurnRates(householdId, userId);
const triggered = burnRates.filter(
(br) => br.daysUntilEmpty !== null && br.daysUntilEmpty <= thresholdDays,
);
if (triggered.length === 0) return [];
// Get strength data from cabinet aggregate
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
for (const s of summaries) {
summaryMap.set(s._id as string, {
medicineStrength: s.medicineStrength as number,
medicineStrengthUnit: s.medicineStrengthUnit as string,
});
}
// Get pending stock from ordered purchases
const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId);
const pendingStockMap = new Map<string, number>();
for (const row of pendingStockRows) {
pendingStockMap.set(row.medicineId, row.totalUnits);
}
const alerts = await Promise.all(
triggered.map(async (br) => {
const summary = summaryMap.get(br.medicineId);
const suggestedQuantity = Math.ceil(br.dailyConsumption * 30);
const pendingOrderStock = pendingStockMap.get(br.medicineId) ?? 0;
const daysUntilEmptyWithOrders =
br.dailyConsumption > 0
? (br.totalInCabinet + pendingOrderStock) / br.dailyConsumption
: null;
const [latestRecord, comparisons] = await Promise.all([
this.medicinePricesRepository.getLatestForMedicine(householdId, br.medicineId),
this.medicinePricesRepository.compareStores(householdId, br.medicineId),
]);
return {
medicineId: br.medicineId,
medicineName: br.medicineName,
medicineStrength: summary?.medicineStrength ?? 0,
medicineStrengthUnit: (summary?.medicineStrengthUnit ?? 'mg') as never,
daysUntilEmpty: br.daysUntilEmpty as number,
dailyConsumption: br.dailyConsumption,
currentStock: br.totalInCabinet,
pendingOrderStock,
daysUntilEmptyWithOrders,
suggestedQuantity,
lastKnownPrice: latestRecord
? {
price: latestRecord.price as number,
pricePerUnit: latestRecord.pricePerUnit as number,
storeName: latestRecord.storeName as string,
storeId: latestRecord.storeId as string,
date: latestRecord.date as Date,
}
: undefined,
cheapestOption:
comparisons.length > 0
? {
price: comparisons[0].latestPrice,
pricePerUnit: comparisons[0].latestPricePerUnit,
storeName: comparisons[0].storeName,
storeId: comparisons[0].storeId,
date: comparisons[0].date,
}
: undefined,
};
}),
);
return alerts;
}
public async createList(data: CreateRefillListInput, householdId: string, userId: string) {
let items: Array<{
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
estimatedPrice?: number;
storeId?: string;
notes?: string;
}> = [];
if (data.fromAlerts) {
const alerts = await this.getAlerts(householdId, userId, data.thresholdDays);
items = alerts.map((alert) => ({
medicineId: alert.medicineId,
medicineName: alert.medicineName,
quantity: alert.suggestedQuantity,
unit: 'tablet' as string,
estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price,
storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId,
}));
} else if (data.items) {
items = data.items.map((item) => ({
medicineId: item.medicineId,
medicineName: item.medicineName,
quantity: item.quantity,
unit: item.unit as string,
estimatedPrice: item.estimatedPrice,
storeId: item.storeId,
notes: item.notes,
}));
}
const totalEstimatedCost =
items.length > 0
? items.reduce((sum, item) => sum + (item.estimatedPrice ?? 0), 0) || undefined
: undefined;
return this.refillsRepository.create({
householdId,
name: data.name,
status: RefillListStatus.ACTIVE,
preferredStoreId: data.preferredStoreId,
totalEstimatedCost,
createdBy: userId,
items,
});
}
public async list(householdId: string, query: RefillListQueryInput) {
return this.refillsRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const list = await this.refillsRepository.findById(id, householdId);
if (!list) throw new NotFoundError('Refill list not found');
return list;
}
public async updateList(id: string, householdId: string, data: UpdateRefillListInput) {
await this.getById(id, householdId);
const updated = await this.refillsRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Refill list not found');
return updated;
}
public async updateItem(
listId: string,
householdId: string,
itemId: string,
data: UpdateRefillListItemInput,
) {
await this.getById(listId, householdId);
const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data };
if (data.checked === true) {
updateData.checkedAt = new Date();
}
const updated = await this.refillsRepository.updateItem(
listId,
householdId,
itemId,
updateData,
);
if (!updated) throw new NotFoundError('Refill list or item not found');
return updated;
}
public async addToCabinet(listId: string, householdId: string, userId: string) {
const list = await this.getById(listId, householdId);
const checkedItems = (list.items as Array<{
_id: { toString: () => string };
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
actualPrice?: number;
storeId?: string;
checked: boolean;
addedToCabinet: boolean;
}>).filter((item) => item.checked && !item.addedToCabinet);
if (checkedItems.length === 0) {
return { addedCount: 0, priceRecordsCreated: 0 };
}
let addedCount = 0;
const addedItemIds: string[] = [];
for (const item of checkedItems) {
await this.cabinetService.addItem(
{
medicineId: item.medicineId,
quantity: item.quantity,
unit: item.unit as never,
unitPrice: item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
totalPrice: item.actualPrice,
storeId: item.storeId,
purchaseDate: new Date().toISOString(),
},
householdId,
userId,
);
addedCount++;
addedItemIds.push(item._id.toString());
}
await this.refillsRepository.markItemsAddedToCabinet(listId, householdId, addedItemIds);
return { addedCount, priceRecordsCreated: 0 };
}
public async getStoreComparison(listId: string, householdId: string) {
const list = await this.getById(listId, householdId);
const medicineIds = [
...new Set(
(list.items as Array<{ medicineId: string }>).map((item) => item.medicineId),
),
];
const comparisons = await Promise.all(
medicineIds.map(async (medicineId) => {
const storeOptions = await this.medicinePricesRepository.compareStores(
householdId,
medicineId,
);
return { medicineId, storeOptions };
}),
);
return comparisons.filter((c) => c.storeOptions.length > 0);
}
}

View file

@ -138,6 +138,7 @@ export class RegimensService {
const earliestExpiry = stock?.earliestExpiry ?? null;
const dailyConsumption = consumption.dailyConsumption;
/* v8 ignore next */
const daysUntilEmpty =
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
@ -163,6 +164,7 @@ export class RegimensService {
}
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
/* v8 ignore next 6 */
burnRates.sort((a, b) => {
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
if (a.daysUntilEmpty === null) return 1;

View file

@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
}));
vi.mock('../../schemas/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 './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,62 @@
import { StoreModel } from '../../schemas/store.schema.js';
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
export class StoresRepository {
public async findByHousehold(householdId: string, query: StoreQueryInput) {
const filter: Record<string, unknown> = { householdId };
if (query.tags) {
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
if (tagList.length > 0) filter['tags'] = { $in: tagList };
}
if (query.search) {
filter['name'] = { $regex: query.search, $options: 'i' };
}
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const limit = query.limit;
const items = await StoreModel.find(filter)
.sort({ _id: 1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string) {
return StoreModel.findOne({ _id: id, householdId }).lean().exec();
}
public async create(data: CreateStoreInput, householdId: string, createdBy: string) {
const store = new StoreModel({ ...data, householdId, createdBy });
const saved = await store.save();
return saved.toObject();
}
public async update(id: string, householdId: string, data: UpdateStoreInput) {
return StoreModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: data },
{ new: true, lean: true },
).exec();
}
public async deactivate(id: string, householdId: string) {
return StoreModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: { isActive: false } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,319 @@
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('./stores.repository.js', () => ({
StoresRepository: class {
findByHousehold = vi.fn();
findById = vi.fn();
create = vi.fn();
update = vi.fn();
deactivate = vi.fn();
},
}));
vi.mock('./stores.service.js', () => ({
StoresService: class {
list = mockList;
getById = mockGetById;
create = mockCreate;
update = mockUpdate;
deactivate = mockDeactivate;
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import storesRoutes from './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,158 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateStoreSchema,
UpdateStoreSchema,
StoreQuerySchema,
StoreResponseSchema,
StoreListResponseSchema,
} from '@meshitrack/shared';
import { StoresRepository } from './stores.repository.js';
import { StoresService } from './stores.service.js';
type AnyStoreDoc = {
_id: string | { toString: () => string };
householdId: string;
name: string;
address?: string;
location?: { lat: number; lng: number };
url?: string;
notes?: string;
tags: string[];
isActive: boolean;
createdBy: string;
createdAt: string | Date | { toISOString: () => string };
updatedAt: string | Date | { toISOString: () => string };
};
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
}
function toStoreResponse(rawDoc: unknown) {
const doc = rawDoc as AnyStoreDoc;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
householdId: doc.householdId,
name: doc.name,
...(doc.address != null ? { address: doc.address } : {}),
...(doc.location != null ? { location: doc.location } : {}),
...(doc.url != null ? { url: doc.url } : {}),
...(doc.notes != null ? { notes: doc.notes } : {}),
tags: doc.tags,
isActive: doc.isActive,
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
storesRepository: StoresRepository;
storesService: StoresService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }),
storesService: asClass(StoresService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/stores',
schema: {
params: householdParams,
querystring: StoreQuerySchema,
response: { 200: StoreListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const result = await service.list(request.params.householdId, request.query);
return reply.send({
data: result.data.map(toStoreResponse),
pagination: result.pagination,
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/stores/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: StoreResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.getById(request.params.id, request.params.householdId);
return reply.send(toStoreResponse(store));
},
});
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/stores',
schema: {
params: householdParams,
body: CreateStoreSchema,
response: { 201: StoreResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toStoreResponse(store));
},
});
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/stores/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdateStoreSchema,
response: { 200: StoreResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toStoreResponse(store));
},
});
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/stores/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: StoreResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.deactivate(request.params.id, request.params.householdId);
return reply.send(toStoreResponse(store));
},
});
},
{
name: 'stores-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StoresService } from './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,43 @@
import type { StoresRepository } from './stores.repository.js';
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
storesRepository: StoresRepository;
}
export class StoresService {
private readonly storesRepository: StoresRepository;
public constructor({ storesRepository }: Deps) {
this.storesRepository = storesRepository;
}
public async list(householdId: string, query: StoreQueryInput) {
return this.storesRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const store = await this.storesRepository.findById(id, householdId);
if (!store) throw new NotFoundError('Store not found');
return store;
}
public async create(data: CreateStoreInput, householdId: string, userId: string) {
return this.storesRepository.create(data, householdId, userId);
}
public async update(id: string, householdId: string, data: UpdateStoreInput) {
await this.getById(id, householdId);
const updated = await this.storesRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Store not found');
return updated;
}
public async deactivate(id: string, householdId: string) {
await this.getById(id, householdId);
const updated = await this.storesRepository.deactivate(id, householdId);
if (!updated) throw new NotFoundError('Store not found');
return updated;
}
}