Phase 5 cleanup
This commit is contained in:
parent
5536acd67d
commit
76a516a417
136 changed files with 6322 additions and 1985 deletions
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -67,9 +67,7 @@ async function migrate() {
|
|||
}
|
||||
}
|
||||
if (dirty) {
|
||||
await db
|
||||
.collection('regimens')
|
||||
.updateOne({ _id: regimen._id }, { $set: { medications } });
|
||||
await db.collection('regimens').updateOne({ _id: regimen._id }, { $set: { medications } });
|
||||
console.log(`regimens: updated medications in regimen ${regimen._id}`);
|
||||
total++;
|
||||
}
|
||||
|
|
@ -88,9 +86,7 @@ async function migrate() {
|
|||
}
|
||||
}
|
||||
if (dirty) {
|
||||
await db
|
||||
.collection('refillists')
|
||||
.updateOne({ _id: list._id }, { $set: { items } });
|
||||
await db.collection('refillists').updateOne({ _id: list._id }, { $set: { items } });
|
||||
console.log(`refillists: updated items in list ${list._id}`);
|
||||
total++;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue