2026-03-28 18:25:49 +09:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
|
import Fastify from 'fastify';
|
|
|
|
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
|
|
|
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
|
|
|
|
|
|
|
|
|
vi.mock('jose', () => ({
|
|
|
|
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
|
|
|
|
jwtVerify: vi.fn().mockResolvedValue({
|
|
|
|
|
payload: {
|
|
|
|
|
sub: 'kc-1',
|
|
|
|
|
email: 'test@example.com',
|
|
|
|
|
preferred_username: 'testuser',
|
|
|
|
|
realm_access: { roles: ['member'] },
|
|
|
|
|
householdIds: ['hh1'],
|
|
|
|
|
},
|
|
|
|
|
protectedHeader: { alg: 'RS256' },
|
|
|
|
|
key: {},
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const { mockListEvents, mockGetEventsByItem, mockGetSpendingSummary } = vi.hoisted(() => ({
|
|
|
|
|
mockListEvents: vi.fn(),
|
|
|
|
|
mockGetEventsByItem: vi.fn(),
|
|
|
|
|
mockGetSpendingSummary: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('./cabinet-events.repository.js', () => ({
|
|
|
|
|
CabinetEventsRepository: class {
|
|
|
|
|
create = vi.fn();
|
|
|
|
|
createMany = vi.fn();
|
|
|
|
|
findByHousehold = vi.fn();
|
|
|
|
|
findByCabinetItem = vi.fn();
|
|
|
|
|
getSpendingSummary = vi.fn();
|
|
|
|
|
getAvgUnitPriceByMedicine = vi.fn();
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('./cabinet-events.service.js', () => ({
|
|
|
|
|
CabinetEventsService: class {
|
|
|
|
|
logEvent = vi.fn();
|
|
|
|
|
logEvents = vi.fn();
|
|
|
|
|
listEvents = mockListEvents;
|
|
|
|
|
getEventsByItem = mockGetEventsByItem;
|
|
|
|
|
getSpendingSummary = mockGetSpendingSummary;
|
|
|
|
|
getAvgUnitPrices = vi.fn();
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('../users/users.repository.js', () => ({
|
|
|
|
|
UsersRepository: class {
|
|
|
|
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
|
|
|
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
import authPlugin from '../../plugins/auth.plugin.js';
|
|
|
|
|
import householdPlugin from '../../plugins/household.plugin.js';
|
|
|
|
|
import usersRoutes from '../users/users.routes.js';
|
|
|
|
|
import cabinetEventsRoutes from './cabinet-events.routes.js';
|
|
|
|
|
|
|
|
|
|
function makeFakeEvent(overrides = {}) {
|
|
|
|
|
return {
|
|
|
|
|
_id: 'ev-1',
|
|
|
|
|
householdId: 'hh1',
|
|
|
|
|
userId: 'kc-1',
|
|
|
|
|
cabinetItemId: 'ci-1',
|
|
|
|
|
medicineId: 'med-1',
|
|
|
|
|
medicineName: 'Metformin',
|
|
|
|
|
eventType: 'purchased',
|
|
|
|
|
quantity: 10,
|
|
|
|
|
quantityBefore: 0,
|
|
|
|
|
quantityAfter: 10,
|
|
|
|
|
sourceType: 'manual',
|
|
|
|
|
createdAt: '2024-06-01T00:00:00.000Z',
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('cabinet-events.routes', () => {
|
|
|
|
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
|
|
|
|
|
|
|
|
|
async function buildTestApp() {
|
|
|
|
|
const instance = Fastify({ logger: false });
|
|
|
|
|
instance.setValidatorCompiler(validatorCompiler);
|
|
|
|
|
instance.setSerializerCompiler(serializerCompiler);
|
|
|
|
|
await instance.register(fastifyAwilixPlugin, {
|
|
|
|
|
disposeOnClose: true,
|
|
|
|
|
disposeOnResponse: true,
|
|
|
|
|
strictBooleanEnforced: true,
|
|
|
|
|
});
|
|
|
|
|
await instance.register(authPlugin);
|
|
|
|
|
await instance.register(householdPlugin);
|
|
|
|
|
await instance.register(usersRoutes);
|
|
|
|
|
await instance.register(cabinetEventsRoutes);
|
|
|
|
|
await instance.ready();
|
|
|
|
|
return instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
app = await buildTestApp();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
if (app) await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('GET /api/v1/households/:householdId/cabinet-events', () => {
|
|
|
|
|
it('returns paginated event list', async () => {
|
|
|
|
|
const event = makeFakeEvent();
|
|
|
|
|
mockListEvents.mockResolvedValue({
|
|
|
|
|
data: [event],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.data).toHaveLength(1);
|
|
|
|
|
expect(body.data[0].medicineName).toBe('Metformin');
|
|
|
|
|
expect(body.data[0].eventType).toBe('purchased');
|
|
|
|
|
expect(body.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles ObjectId and Date objects in response', async () => {
|
|
|
|
|
const event = makeFakeEvent({
|
|
|
|
|
_id: { toString: () => 'ev-obj' },
|
|
|
|
|
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
|
|
|
|
|
unitPrice: 5.5,
|
|
|
|
|
totalPrice: 55,
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
storeId: 'store-1',
|
|
|
|
|
storeName: 'Pharmacy A',
|
|
|
|
|
sourceId: 'src-1',
|
|
|
|
|
reason: 'restocking',
|
|
|
|
|
notes: 'bulk purchase',
|
|
|
|
|
});
|
|
|
|
|
mockListEvents.mockResolvedValue({
|
|
|
|
|
data: [event],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.data[0]._id).toBe('ev-obj');
|
|
|
|
|
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
|
|
|
|
|
expect(body.data[0].unitPrice).toBe(5.5);
|
|
|
|
|
expect(body.data[0].totalPrice).toBe(55);
|
|
|
|
|
expect(body.data[0].currency).toBe('USD');
|
|
|
|
|
expect(body.data[0].storeId).toBe('store-1');
|
|
|
|
|
expect(body.data[0].storeName).toBe('Pharmacy A');
|
|
|
|
|
expect(body.data[0].sourceId).toBe('src-1');
|
|
|
|
|
expect(body.data[0].reason).toBe('restocking');
|
|
|
|
|
expect(body.data[0].notes).toBe('bulk purchase');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles Date instances in createdAt', async () => {
|
|
|
|
|
const event = makeFakeEvent({
|
|
|
|
|
createdAt: new Date('2024-03-15T12:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
mockListEvents.mockResolvedValue({
|
|
|
|
|
data: [event],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.data[0].createdAt).toBe('2024-03-15T12:00:00.000Z');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('omits null optional fields from response', async () => {
|
|
|
|
|
const event = makeFakeEvent({
|
|
|
|
|
unitPrice: null,
|
|
|
|
|
totalPrice: null,
|
|
|
|
|
currency: null,
|
|
|
|
|
storeId: null,
|
|
|
|
|
storeName: null,
|
|
|
|
|
sourceId: null,
|
|
|
|
|
reason: null,
|
|
|
|
|
notes: null,
|
|
|
|
|
});
|
|
|
|
|
mockListEvents.mockResolvedValue({
|
|
|
|
|
data: [event],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.data[0].unitPrice).toBeUndefined();
|
|
|
|
|
expect(body.data[0].totalPrice).toBeUndefined();
|
|
|
|
|
expect(body.data[0].currency).toBeUndefined();
|
|
|
|
|
expect(body.data[0].storeId).toBeUndefined();
|
|
|
|
|
expect(body.data[0].storeName).toBeUndefined();
|
|
|
|
|
expect(body.data[0].sourceId).toBeUndefined();
|
|
|
|
|
expect(body.data[0].reason).toBeUndefined();
|
|
|
|
|
expect(body.data[0].notes).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes query parameters to service', async () => {
|
|
|
|
|
mockListEvents.mockResolvedValue({
|
|
|
|
|
data: [],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events?medicineId=med-1&eventType=purchased&limit=10',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
2026-04-26 18:44:59 +09:00
|
|
|
expect(mockListEvents).toHaveBeenCalledWith(
|
|
|
|
|
'hh1',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
medicineId: 'med-1',
|
|
|
|
|
eventType: 'purchased',
|
|
|
|
|
limit: 10,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-03-28 18:25:49 +09:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId', () => {
|
|
|
|
|
it('returns paginated events for a cabinet item', async () => {
|
|
|
|
|
const event = makeFakeEvent();
|
|
|
|
|
mockGetEventsByItem.mockResolvedValue({
|
|
|
|
|
data: [event],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.data).toHaveLength(1);
|
|
|
|
|
expect(body.data[0].cabinetItemId).toBe('ci-1');
|
|
|
|
|
expect(body.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes query parameters to service', async () => {
|
|
|
|
|
mockGetEventsByItem.mockResolvedValue({
|
|
|
|
|
data: [],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1?limit=5&cursor=abc',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
2026-04-26 18:44:59 +09:00
|
|
|
expect(mockGetEventsByItem).toHaveBeenCalledWith(
|
|
|
|
|
'hh1',
|
|
|
|
|
'ci-1',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
limit: 5,
|
|
|
|
|
cursor: 'abc',
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-03-28 18:25:49 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles ObjectId and Date objects in by-item response', async () => {
|
|
|
|
|
const event = makeFakeEvent({
|
|
|
|
|
_id: { toString: () => 'ev-obj-2' },
|
|
|
|
|
createdAt: new Date('2024-05-01T00:00:00.000Z'),
|
|
|
|
|
});
|
|
|
|
|
mockGetEventsByItem.mockResolvedValue({
|
|
|
|
|
data: [event],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.data[0]._id).toBe('ev-obj-2');
|
|
|
|
|
expect(body.data[0].createdAt).toBe('2024-05-01T00:00:00.000Z');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('GET /api/v1/households/:householdId/cabinet-events/spending-summary', () => {
|
|
|
|
|
it('returns spending summary', async () => {
|
|
|
|
|
mockGetSpendingSummary.mockResolvedValue({
|
|
|
|
|
totalSpent: 250,
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
byMedicine: [
|
|
|
|
|
{
|
|
|
|
|
medicineId: 'med-1',
|
|
|
|
|
medicineName: 'Metformin',
|
|
|
|
|
totalSpent: 250,
|
|
|
|
|
totalQuantity: 25,
|
|
|
|
|
avgUnitPrice: 10,
|
|
|
|
|
purchaseCount: 5,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
byPeriod: [
|
|
|
|
|
{ period: '2024-01', totalSpent: 100 },
|
|
|
|
|
{ period: '2024-02', totalSpent: 150 },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.totalSpent).toBe(250);
|
|
|
|
|
expect(body.currency).toBe('USD');
|
|
|
|
|
expect(body.byMedicine).toHaveLength(1);
|
|
|
|
|
expect(body.byMedicine[0].medicineId).toBe('med-1');
|
|
|
|
|
expect(body.byPeriod).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes query parameters to service', async () => {
|
|
|
|
|
mockGetSpendingSummary.mockResolvedValue({
|
|
|
|
|
totalSpent: 0,
|
|
|
|
|
currency: null,
|
|
|
|
|
byMedicine: [],
|
|
|
|
|
byPeriod: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events/spending-summary?period=quarter&medicineId=med-1',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
2026-04-26 18:44:59 +09:00
|
|
|
expect(mockGetSpendingSummary).toHaveBeenCalledWith(
|
|
|
|
|
'hh1',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
period: 'quarter',
|
|
|
|
|
medicineId: 'med-1',
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-03-28 18:25:49 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns empty summary with null currency', async () => {
|
|
|
|
|
mockGetSpendingSummary.mockResolvedValue({
|
|
|
|
|
totalSpent: 0,
|
|
|
|
|
currency: null,
|
|
|
|
|
byMedicine: [],
|
|
|
|
|
byPeriod: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
|
|
|
|
|
headers: authHeaders,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json();
|
|
|
|
|
expect(body.totalSpent).toBe(0);
|
|
|
|
|
expect(body.currency).toBeNull();
|
|
|
|
|
expect(body.byMedicine).toHaveLength(0);
|
|
|
|
|
expect(body.byPeriod).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|