Phase 5 cleanup

This commit is contained in:
Aerilyn Weber 2026-04-26 18:44:59 +09:00
parent 5536acd67d
commit 76a516a417
136 changed files with 6322 additions and 1985 deletions

View file

@ -235,9 +235,7 @@ describe(CabinetEventsRepository.name, () => {
];
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
mockAggregate
.mockResolvedValueOnce(byMedicine)
.mockResolvedValueOnce(byPeriod);
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce(byPeriod);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
@ -256,9 +254,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('returns null currency when no medicine data', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
@ -269,9 +265,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by medicineId', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
@ -279,9 +273,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by startDate only', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
@ -292,9 +284,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by endDate only', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
@ -305,9 +295,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by both startDate and endDate', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
@ -319,9 +307,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('uses quarter date format', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'quarter' });
@ -329,9 +315,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('uses year date format', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'year' });
@ -350,9 +334,7 @@ describe(CabinetEventsRepository.name, () => {
currency: null,
},
];
mockAggregate
.mockResolvedValueOnce(byMedicine)
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });

View file

@ -130,7 +130,11 @@ export class CabinetEventsRepository {
{
$addFields: {
avgUnitPrice: {
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
$cond: [
{ $gt: ['$totalQuantity', 0] },
{ $divide: ['$totalSpent', '$totalQuantity'] },
0,
],
},
},
},
@ -153,7 +157,7 @@ export class CabinetEventsRepository {
0,
);
const currency =
byMedicine.length > 0 ? (byMedicine[0].currency as string | null) ?? null : null;
byMedicine.length > 0 ? ((byMedicine[0].currency as string | null) ?? null) : null;
return {
totalSpent,
@ -174,7 +178,8 @@ export class CabinetEventsRepository {
}
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
if (medicineIds.length === 0) return new Map<string, { avgUnitPrice: number; currency: string | null }>();
if (medicineIds.length === 0)
return new Map<string, { avgUnitPrice: number; currency: string | null }>();
const results = await CabinetEventModel.aggregate([
{
@ -196,7 +201,11 @@ export class CabinetEventsRepository {
{
$addFields: {
avgUnitPrice: {
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
$cond: [
{ $gt: ['$totalQuantity', 0] },
{ $divide: ['$totalSpent', '$totalQuantity'] },
0,
],
},
},
},

View file

@ -234,11 +234,14 @@ describe('cabinet-events.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockListEvents).toHaveBeenCalledWith('hh1', expect.objectContaining({
medicineId: 'med-1',
eventType: 'purchased',
limit: 10,
}));
expect(mockListEvents).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({
medicineId: 'med-1',
eventType: 'purchased',
limit: 10,
}),
);
});
});
@ -276,10 +279,14 @@ describe('cabinet-events.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockGetEventsByItem).toHaveBeenCalledWith('hh1', 'ci-1', expect.objectContaining({
limit: 5,
cursor: 'abc',
}));
expect(mockGetEventsByItem).toHaveBeenCalledWith(
'hh1',
'ci-1',
expect.objectContaining({
limit: 5,
cursor: 'abc',
}),
);
});
it('handles ObjectId and Date objects in by-item response', async () => {
@ -356,10 +363,13 @@ describe('cabinet-events.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.objectContaining({
period: 'quarter',
medicineId: 'med-1',
}));
expect(mockGetSpendingSummary).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({
period: 'quarter',
medicineId: 'med-1',
}),
);
});
it('returns empty summary with null currency', async () => {

View file

@ -142,10 +142,7 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const summary = await service.getSpendingSummary(
request.params.householdId,
request.query,
);
const summary = await service.getSpendingSummary(request.params.householdId, request.query);
return reply.send(summary);
},
});

View file

@ -136,7 +136,9 @@ describe(CabinetEventsService.name, () => {
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', ['med-1']);
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', [
'med-1',
]);
});
});
});

View file

@ -1,4 +1,7 @@
import type { CabinetEventsRepository, CreateCabinetEventData } from './cabinet-events.repository.js';
import type {
CabinetEventsRepository,
CreateCabinetEventData,
} from './cabinet-events.repository.js';
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
interface Deps {

View file

@ -1,5 +1,9 @@
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
import type { CabinetItemStatus, CreateCabinetItemInput, UpdateCabinetItemInput } from '@meshitrack/shared';
import type {
CabinetItemStatus,
CreateCabinetItemInput,
UpdateCabinetItemInput,
} from '@meshitrack/shared';
interface FindByHouseholdQuery {
medicineId?: string;

View file

@ -249,7 +249,11 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
await service.delete(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(204).send();
},
});

View file

@ -239,7 +239,12 @@ describe(CabinetService.name, () => {
describe('update', () => {
it('updates and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 25 };
mockCabinetRepo.update.mockResolvedValue(updated);
@ -249,7 +254,12 @@ describe(CabinetService.name, () => {
});
it('logs ADJUSTED event when quantity changes', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
@ -265,7 +275,12 @@ describe(CabinetService.name, () => {
});
it('does not log event when quantity unchanged', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
@ -282,7 +297,12 @@ describe(CabinetService.name, () => {
});
it('throws NotFoundError when update returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue(null);
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
@ -293,7 +313,12 @@ describe(CabinetService.name, () => {
describe('adjustQuantity', () => {
it('adjusts quantity and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 27 };
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
@ -303,7 +328,12 @@ describe(CabinetService.name, () => {
});
it('logs ADJUSTED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
@ -334,7 +364,12 @@ describe(CabinetService.name, () => {
});
it('throws NotFoundError when adjust returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
@ -357,7 +392,12 @@ describe(CabinetService.name, () => {
describe('delete', () => {
it('soft deletes item and logs DELETED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
const result = await service.delete('ci-1', 'hh1', 'user-1');
@ -376,20 +416,34 @@ describe(CabinetService.name, () => {
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 5, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 5,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('discard', () => {
it('discards item and logs DISCARDED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 20, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 20,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
@ -409,7 +463,12 @@ describe(CabinetService.name, () => {
});
it('throws BadRequestError when quantity is zero', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 0, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 0,
medicineId: 'med-1',
medicineName: 'Test',
});
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cannot discard an item with zero quantity',
@ -425,7 +484,12 @@ describe(CabinetService.name, () => {
});
it('throws NotFoundError when discard returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue(null);
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(

View file

@ -2,10 +2,7 @@ import type { CabinetRepository } from './cabinet.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import {
CabinetEventType,
CabinetEventSourceType,
} from '@meshitrack/shared';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
import type {
CreateCabinetItemInput,
UpdateCabinetItemInput,
@ -123,7 +120,12 @@ export class CabinetService {
return item;
}
public async update(id: string, householdId: string, data: UpdateCabinetItemInput, userId: string) {
public async update(
id: string,
householdId: string,
data: UpdateCabinetItemInput,
userId: string,
) {
const existing = await this.getById(id, householdId);
const updated = await this.cabinetRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Cabinet item not found');
@ -203,7 +205,13 @@ export class CabinetService {
return deleted;
}
public async discard(id: string, householdId: string, userId: string, reason: string, notes?: string) {
public async discard(
id: string,
householdId: string,
userId: string,
reason: string,
notes?: string,
) {
const existing = await this.getById(id, householdId);
if (existing.quantity === 0) {
throw new BadRequestError('Cannot discard an item with zero quantity');

View file

@ -23,9 +23,13 @@ vi.mock('../../schemas/medicine-price.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static aggregate = vi.fn(() => aggregateChain());
@ -231,8 +235,17 @@ describe(MedicinePricesRepository.name, () => {
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([
{
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' });

View file

@ -1,5 +1,8 @@
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared';
import type {
MedicinePriceHistoryQueryInput,
MedicinePriceAnalyticsQueryInput,
} from '@meshitrack/shared';
export interface CreateMedicinePriceData {
householdId: string;
@ -93,11 +96,7 @@ export class MedicinePricesRepository {
}));
}
public async getLatestForMedicine(
householdId: string,
medicineId: string,
storeId?: string,
) {
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();
@ -132,7 +131,15 @@ export class MedicinePricesRepository {
},
{ $sort: { totalSpent: -1 } },
{ $limit: 10 },
{ $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } },
{
$project: {
_id: 0,
medicineId: '$_id',
medicineName: 1,
totalSpent: 1,
avgPricePerUnit: 1,
},
},
]).exec(),
MedicinePriceModel.aggregate([
@ -195,9 +202,26 @@ export class MedicinePricesRepository {
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 }[],
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

@ -18,17 +18,14 @@ vi.mock('jose', () => ({
}),
}));
const {
mockRecordPrice,
mockGetPriceHistory,
mockCompareStores,
mockGetAnalytics,
} = vi.hoisted(() => ({
mockRecordPrice: vi.fn(),
mockGetPriceHistory: vi.fn(),
mockCompareStores: vi.fn(),
mockGetAnalytics: vi.fn(),
}));
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 {
@ -198,11 +195,13 @@ describe('medicine-prices.routes', () => {
});
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'),
}));
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',
@ -238,7 +237,10 @@ describe('medicine-prices.routes', () => {
});
it('passes query params to service', async () => {
mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
mockGetPriceHistory.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
await app.inject({
method: 'GET',

View file

@ -13,8 +13,6 @@ import {
} 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 };

View file

@ -40,7 +40,10 @@ describe(MedicinePricesService.name, () => {
};
it('creates price record with computed pricePerUnit', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
mockPricesRepo.create.mockResolvedValue(record);
@ -49,7 +52,11 @@ describe(MedicinePricesService.name, () => {
expect(result).toEqual(record);
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ pricePerUnit: 0.1, medicineName: 'Acetaminophen', storeName: 'Walgreens' }),
expect.objectContaining({
pricePerUnit: 0.1,
medicineName: 'Acetaminophen',
storeName: 'Walgreens',
}),
);
});
@ -66,11 +73,18 @@ describe(MedicinePricesService.name, () => {
});
it('uses provided date when given', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
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');
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') }),
@ -86,7 +100,10 @@ describe(MedicinePricesService.name, () => {
});
it('throws NotFoundError when store not found', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue(null);
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(

View file

@ -19,7 +19,11 @@ export class MedicinePricesService {
private readonly medicineProductsRepository: MedicineProductsRepository;
private readonly storesRepository: StoresRepository;
public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) {
public constructor({
medicinePricesRepository,
medicineProductsRepository,
storesRepository,
}: Deps) {
this.medicinePricesRepository = medicinePricesRepository;
this.medicineProductsRepository = medicineProductsRepository;
this.storesRepository = storesRepository;

View file

@ -179,7 +179,9 @@ describe('medicine-products.routes', () => {
});
it('includes concentration fields in response when present', async () => {
mockFindById.mockResolvedValue(makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }));
mockFindById.mockResolvedValue(
makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }),
);
const res = await app.inject({
method: 'GET',

View file

@ -74,7 +74,10 @@ describe(OrganizerRepository.name, () => {
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `fill-${i}`, regimenName: `R${i}` }));
const items = Array.from({ length: 3 }, (_, i) => ({
_id: `fill-${i}`,
regimenName: `R${i}`,
}));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });

View file

@ -76,9 +76,7 @@ function makeFakeFill(overrides = {}) {
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [
{ cabinetItemId: 'ci-1', quantityTaken: 7 },
],
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
},
],
status: OrganizerFillStatus.COMPLETED,
@ -182,11 +180,15 @@ describe('organizer.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockListFills).toHaveBeenCalledWith('hh1', 'kc-1', expect.objectContaining({
regimenId: 'reg-1',
status: OrganizerFillStatus.COMPLETED,
limit: 10,
}));
expect(mockListFills).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({
regimenId: 'reg-1',
status: OrganizerFillStatus.COMPLETED,
limit: 10,
}),
);
});
it('handles ObjectId and Date serialization in fill response', async () => {
@ -439,7 +441,12 @@ describe('organizer.routes', () => {
expect(mockFill).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({ regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' }),
expect.objectContaining({
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: false,
notes: 'test note',
}),
);
});
});

View file

@ -82,7 +82,9 @@ describe(OrganizerService.name, () => {
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', {
limit: 20,
});
});
});

View file

@ -66,7 +66,12 @@ export class OrganizerService {
return fill;
}
public async preview(householdId: string, userId: string, regimenId: string, numberOfDays: number) {
public async preview(
householdId: string,
userId: string,
regimenId: string,
numberOfDays: number,
) {
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
if (!regimen.isActive) {
throw new BadRequestError('Regimen is not active');
@ -132,7 +137,12 @@ export class OrganizerService {
}
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
const previewResult = await this.preview(householdId, userId, input.regimenId, input.numberOfDays);
const previewResult = await this.preview(
householdId,
userId,
input.regimenId,
input.numberOfDays,
);
if (!input.allowPartial && previewResult.hasShortages) {
throw new BadRequestError(
@ -243,7 +253,10 @@ export class OrganizerService {
for (const item of fill.items) {
for (const deduction of item.deductions) {
// Get current quantity before restoring
const current = await this.cabinetRepository.findById(deduction.cabinetItemId, householdId);
const current = await this.cabinetRepository.findById(
deduction.cabinetItemId,
householdId,
);
const quantityBefore = current?.quantity ?? 0;
await this.cabinetRepository.adjustQuantity(

View file

@ -21,9 +21,13 @@ vi.mock('../../schemas/purchase.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => findChain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
@ -54,7 +58,15 @@ describe(PurchasesRepository.name, () => {
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' };
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);
@ -104,9 +116,7 @@ describe(PurchasesRepository.name, () => {
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
expect(PurchaseModel.find).toHaveBeenCalledWith(
expect.objectContaining({ storeId: 'st-1' }),
);
expect(PurchaseModel.find).toHaveBeenCalledWith(expect.objectContaining({ storeId: 'st-1' }));
});
it('applies cursor filter when provided', async () => {

View file

@ -53,9 +53,7 @@ export class PurchasesRepository {
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;
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}

View file

@ -18,21 +18,16 @@ vi.mock('jose', () => ({
}),
}));
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(),
}));
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 {

View file

@ -48,11 +48,7 @@ type AnyPurchase = {
function toItemResponse(item: AnyPurchaseItem) {
return {
_id: item._id
? typeof item._id === 'string'
? item._id
: item._id.toString()
: '',
_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 } : {}),

View file

@ -38,7 +38,12 @@ describe(PurchasesService.name, () => {
});
const fakeStore = { _id: 'st-1', name: 'CVS' };
const fakeProduct = { _id: 'mp-1', medicineId: 'med-1', medicineName: 'Ibuprofen', brand: 'Advil' };
const fakeProduct = {
_id: 'mp-1',
medicineId: 'med-1',
medicineName: 'Ibuprofen',
brand: 'Advil',
};
describe('create', () => {
const validInput = {
@ -165,7 +170,9 @@ describe(PurchasesService.name, () => {
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');
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow(
'Purchase not found',
);
});
it('throws BadRequestError when status is not ordered', async () => {
@ -309,7 +316,14 @@ describe(PurchasesService.name, () => {
storeName: 'CVS',
purchasedAt: new Date(),
items: [
{ medicineProductId: 'mp-1', medicineId: 'med-1', name: 'X', quantity: 10, unit: 'tablet', addedToCabinet: true },
{
medicineProductId: 'mp-1',
medicineId: 'med-1',
name: 'X',
quantity: 10,
unit: 'tablet',
addedToCabinet: true,
},
],
};
mockPurchasesRepo.findById.mockResolvedValue(purchase);

View file

@ -8,7 +8,7 @@ import type {
UpdatePurchaseInput,
PurchaseQueryInput,
} from '@meshitrack/shared';
import { DosageUnit } from '@meshitrack/shared';
import { type DosageUnit } from '@meshitrack/shared';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
interface Deps {
@ -67,7 +67,8 @@ export class PurchasesService {
item.medicineProductId,
householdId,
);
if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
if (!product)
throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
if (!resolvedName || resolvedName === item.name) {
resolvedName = product.brand ?? resolvedName;
}
@ -128,7 +129,8 @@ export class PurchasesService {
quantity: item.quantity,
unit: item.unit,
/* v8 ignore next */
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
pricePerUnit:
item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
date: purchasedAt,
isInsurancePrice: false,
createdBy: userId,
@ -259,9 +261,7 @@ export class PurchasesService {
return deleted;
}
public async getPendingStockByMedicine(
householdId: string,
): Promise<Map<string, number>> {
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) {

View file

@ -19,9 +19,13 @@ vi.mock('../../schemas/refill-list.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());

View file

@ -1,5 +1,9 @@
import { RefillListModel } from '../../schemas/refill-list.schema.js';
import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared';
import type {
RefillListQueryInput,
UpdateRefillListInput,
UpdateRefillListItemInput,
} from '@meshitrack/shared';
export interface CreateRefillListData {
householdId: string;
@ -46,9 +50,7 @@ export class RefillsRepository {
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;
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}

View file

@ -131,8 +131,20 @@ describe('refills.routes', () => {
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') },
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'),
},
},
]);
@ -265,26 +277,28 @@ describe('refills.routes', () => {
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',
},
],
})],
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 },
});
@ -322,22 +336,24 @@ describe('refills.routes', () => {
});
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,
},
],
}));
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',

View file

@ -45,7 +45,13 @@ describe(RefillsService.name, () => {
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 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 1,
totalInCabinet: 100,
daysUntilEmpty: 100,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
@ -55,7 +61,13 @@ describe(RefillsService.name, () => {
it('returns alerts for medicines below threshold', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 10, daysUntilEmpty: 5 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 10,
daysUntilEmpty: 5,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
@ -73,7 +85,13 @@ describe(RefillsService.name, () => {
it('attaches lastKnownPrice when available', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
@ -93,12 +111,25 @@ describe(RefillsService.name, () => {
it('attaches cheapestOption from compareStores', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
{
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() },
{
storeId: 'st-1',
storeName: 'CVS',
latestPrice: 8,
latestPricePerUnit: 0.08,
currency: 'USD',
date: new Date(),
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
@ -109,7 +140,13 @@ describe(RefillsService.name, () => {
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 4, daysUntilEmpty: 2 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 4,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
@ -126,7 +163,13 @@ describe(RefillsService.name, () => {
it('excludes medicines with null daysUntilEmpty', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 0, daysUntilEmpty: null },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 0,
daysUntilEmpty: null,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
@ -145,7 +188,9 @@ describe(RefillsService.name, () => {
name: 'My List',
fromAlerts: false,
thresholdDays: 7,
items: [{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never }],
items: [
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never },
],
},
'hh1',
'user-1',
@ -160,7 +205,11 @@ describe(RefillsService.name, () => {
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');
await service.createList(
{ name: 'Empty List', fromAlerts: false, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
@ -176,8 +225,20 @@ describe(RefillsService.name, () => {
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 },
{
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',
@ -191,20 +252,28 @@ describe(RefillsService.name, () => {
it('creates list from alerts when fromAlerts is true', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
{
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');
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' }),
]),
items: expect.arrayContaining([expect.objectContaining({ medicineId: 'med-1' })]),
}),
);
});
@ -251,7 +320,9 @@ describe(RefillsService.name, () => {
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow('Refill list not found');
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when update returns null', async () => {
@ -290,7 +361,9 @@ describe(RefillsService.name, () => {
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');
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when item not found', async () => {
@ -308,7 +381,15 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false },
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
@ -324,7 +405,16 @@ describe(RefillsService.name, () => {
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 },
{
_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' });
@ -343,7 +433,15 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false },
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
});
@ -357,7 +455,15 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: true },
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: true,
},
],
});

View file

@ -9,7 +9,6 @@ import type {
UpdateRefillListInput,
UpdateRefillListItemInput,
RefillListQueryInput,
RefillAlertQueryInput,
} from '@meshitrack/shared';
import { RefillListStatus } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
@ -58,7 +57,10 @@ export class RefillsService {
// Get strength data from cabinet aggregate
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
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,
@ -217,17 +219,19 @@ export class RefillsService {
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);
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 };
@ -242,9 +246,10 @@ export class RefillsService {
medicineId: item.medicineId,
quantity: item.quantity,
unit: item.unit as never,
unitPrice: item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
unitPrice:
item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
totalPrice: item.actualPrice,
storeId: item.storeId,
purchaseDate: new Date().toISOString(),
@ -265,9 +270,7 @@ export class RefillsService {
const list = await this.getById(listId, householdId);
const medicineIds = [
...new Set(
(list.items as Array<{ medicineId: string }>).map((item) => item.medicineId),
),
...new Set((list.items as Array<{ medicineId: string }>).map((item) => item.medicineId)),
];
const comparisons = await Promise.all(

View file

@ -68,7 +68,12 @@ export class RegimensRepository {
.exec();
}
public async create(data: CreateRegimenData, householdId: string, userId: string, createdBy: string) {
public async create(
data: CreateRegimenData,
householdId: string,
userId: string,
createdBy: string,
) {
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
const saved = await regimen.save();
return saved.toObject();

View file

@ -19,14 +19,15 @@ vi.mock('jose', () => ({
}),
}));
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } = vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockCalculateBurnRates: vi.fn(),
}));
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } =
vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockCalculateBurnRates: vi.fn(),
}));
vi.mock('./regimens.repository.js', () => ({
RegimensRepository: class {
@ -467,7 +468,12 @@ describe('regimens.routes', () => {
payload: { isActive: false },
});
expect(mockUpdate).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1', expect.objectContaining({ isActive: false }));
expect(mockUpdate).toHaveBeenCalledWith(
'reg-1',
'hh1',
'kc-1',
expect.objectContaining({ isActive: false }),
);
});
});

View file

@ -65,7 +65,9 @@ function toRegimenResponse(doc: AnyRegimenDoc) {
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
...(med.customFrequencyPerDay != null ? { customFrequencyPerDay: med.customFrequencyPerDay } : {}),
...(med.customFrequencyPerDay != null
? { customFrequencyPerDay: med.customFrequencyPerDay }
: {}),
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
...(med.instructions ? { instructions: med.instructions } : {}),
})),
@ -143,7 +145,11 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const regimen = await service.getById(request.params.id, request.params.householdId, request.user.keycloakId);
const regimen = await service.getById(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.send(toRegimenResponse(regimen));
},
});
@ -199,7 +205,11 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
await service.delete(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(204).send();
},
});

View file

@ -82,7 +82,9 @@ describe(RegimensService.name, () => {
it('throws NotFoundError when not found', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
'Regimen not found',
);
});
});
@ -108,7 +110,11 @@ describe(RegimensService.name, () => {
strengthUnit: 'mg',
form: 'tablet',
});
const created = { _id: 'reg-1', ...createInput, medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }] };
const created = {
_id: 'reg-1',
...createInput,
medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }],
};
mockRegimensRepo.create.mockResolvedValue(created);
const result = await service.create(createInput, 'hh1', 'user-1');
@ -255,7 +261,9 @@ describe(RegimensService.name, () => {
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
expect(result).toEqual(updated);
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { name: 'Evening' });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
name: 'Evening',
});
});
it('updates isActive only', async () => {
@ -264,7 +272,9 @@ describe(RegimensService.name, () => {
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { isActive: false });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
isActive: false,
});
});
it('updates medications with denormalization', async () => {
@ -304,9 +314,9 @@ describe(RegimensService.name, () => {
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
'Regimen not found',
);
await expect(
service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' }),
).rejects.toThrow('Regimen not found');
});
it('throws NotFoundError when update returns null', async () => {
@ -360,7 +370,9 @@ describe(RegimensService.name, () => {
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
'Regimen not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
@ -444,13 +456,23 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
{
_id: 'reg-2',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
]);
@ -479,7 +501,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
@ -498,7 +525,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Ibuprofen', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{
medicineId: 'med-1',
medicineName: 'Ibuprofen',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
@ -562,9 +594,24 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-3',
medicineName: 'Med C',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
@ -590,8 +637,18 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
@ -606,9 +663,24 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-3',
medicineName: 'Med C',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
@ -634,7 +706,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Weekly Med', dosage: 1, frequency: DosageFrequency.WEEKLY },
{
medicineId: 'med-1',
medicineName: 'Weekly Med',
dosage: 1,
frequency: DosageFrequency.WEEKLY,
},
],
},
]);
@ -656,7 +733,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'EOD Med', dosage: 1, frequency: DosageFrequency.EVERY_OTHER_DAY },
{
medicineId: 'med-1',
medicineName: 'EOD Med',
dosage: 1,
frequency: DosageFrequency.EVERY_OTHER_DAY,
},
],
},
]);
@ -678,7 +760,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'TID Med', dosage: 1, frequency: DosageFrequency.THREE_TIMES_DAILY },
{
medicineId: 'med-1',
medicineName: 'TID Med',
dosage: 1,
frequency: DosageFrequency.THREE_TIMES_DAILY,
},
],
},
]);
@ -699,7 +786,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Expensive Med', dosage: 2, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Expensive Med',
dosage: 2,
frequency: DosageFrequency.DAILY,
},
],
},
]);
@ -725,8 +817,18 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
]);
@ -737,9 +839,7 @@ describe(RegimensService.name, () => {
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([
['med-1', { avgUnitPrice: 2.0, currency: 'USD' }],
]),
new Map([['med-1', { avgUnitPrice: 2.0, currency: 'USD' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');

View file

@ -82,10 +82,7 @@ export class RegimensService {
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
// Sum daily consumption per medicine across all active regimens
const consumptionMap = new Map<
string,
{ medicineName: string; dailyConsumption: number }
>();
const consumptionMap = new Map<string, { medicineName: string; dailyConsumption: number }>();
for (const regimen of regimens) {
for (const med of regimen.medications) {
@ -115,10 +112,7 @@ export class RegimensService {
// Get cabinet summary for all medicines in regimens
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
const stockMap = new Map<
string,
{ totalQuantity: number; earliestExpiry: Date | null }
>();
const stockMap = new Map<string, { totalQuantity: number; earliestExpiry: Date | null }>();
for (const s of summaryResults) {
stockMap.set(s._id as string, {
totalQuantity: s.totalQuantity as number,

View file

@ -19,9 +19,13 @@ vi.mock('../../schemas/store.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());

View file

@ -6,7 +6,10 @@ export class StoresRepository {
const filter: Record<string, unknown> = { householdId };
if (query.tags) {
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
const tagList = query.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
if (tagList.length > 0) filter['tags'] = { $in: tagList };
}

View file

@ -140,11 +140,13 @@ describe('stores.routes', () => {
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'),
})],
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 },
});
@ -162,12 +164,14 @@ describe('stores.routes', () => {
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',
})],
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 },
});

View file

@ -49,7 +49,11 @@ describe(StoresService.name, () => {
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');
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');