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