import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Fastify from 'fastify'; import { fastifyAwilixPlugin } from '@fastify/awilix'; import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => 'mock-jwks'), jwtVerify: vi.fn().mockResolvedValue({ payload: { sub: 'kc-1', email: 'test@example.com', preferred_username: 'testuser', realm_access: { roles: ['member'] }, householdIds: ['hh1'], }, protectedHeader: { alg: 'RS256' }, key: {}, }), })); const { mockRecordPrice, mockGetPriceHistory, mockCompareStores, mockGetAnalytics, } = vi.hoisted(() => ({ mockRecordPrice: vi.fn(), mockGetPriceHistory: vi.fn(), mockCompareStores: vi.fn(), mockGetAnalytics: vi.fn(), })); vi.mock('./medicine-prices.repository.js', () => ({ MedicinePricesRepository: class { create = vi.fn(); findByMedicine = vi.fn(); compareStores = vi.fn(); getLatestForMedicine = vi.fn(); getAnalytics = vi.fn(); }, })); vi.mock('./medicine-prices.service.js', () => ({ MedicinePricesService: class { recordPrice = mockRecordPrice; getPriceHistory = mockGetPriceHistory; compareStores = mockCompareStores; getAnalytics = mockGetAnalytics; }, })); vi.mock('../medicine-products/medicine-products.repository.js', () => ({ MedicineProductsRepository: class { findById = vi.fn(); }, })); vi.mock('../stores/stores.repository.js', () => ({ StoresRepository: class { findById = vi.fn(); }, })); vi.mock('../users/users.repository.js', () => ({ UsersRepository: class { findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); }, })); import authPlugin from '../../plugins/auth.plugin.js'; import householdPlugin from '../../plugins/household.plugin.js'; import usersRoutes from '../users/users.routes.js'; import medicinePricesRoutes from './medicine-prices.routes.js'; function makeFakePriceRecord(overrides = {}) { return { _id: 'pr-1', householdId: 'hh1', medicineProductId: 'mp-1', medicineProductBrand: 'Tylenol', medicineId: 'med-1', medicineName: 'Acetaminophen', storeId: 'st-1', storeName: 'Walgreens', price: 10, currency: 'USD', quantity: 100, unit: 'tablet', pricePerUnit: 0.1, date: '2026-01-15T00:00:00.000Z', isInsurancePrice: false, createdBy: 'kc-1', createdAt: '2026-01-15T00:00:00.000Z', ...overrides, }; } describe('medicine-prices.routes', () => { let app: Awaited>; async function buildTestApp() { const instance = Fastify({ logger: false }); instance.setValidatorCompiler(validatorCompiler); instance.setSerializerCompiler(serializerCompiler); await instance.register(fastifyAwilixPlugin, { disposeOnClose: true, disposeOnResponse: true, strictBooleanEnforced: true, }); await instance.register(authPlugin); await instance.register(householdPlugin); await instance.register(usersRoutes); await instance.register(medicinePricesRoutes); await instance.ready(); return instance; } const authHeaders = { authorization: 'Bearer valid-token' }; beforeEach(async () => { vi.clearAllMocks(); app = await buildTestApp(); }); afterEach(async () => { if (app) await app.close(); }); describe('POST /api/v1/households/:householdId/medicine-prices', () => { const validBody = { medicineProductId: 'mp-1', medicineId: 'med-1', storeId: 'st-1', price: 10, currency: 'USD', quantity: 100, unit: 'tablet', isInsurancePrice: false, }; it('records price and returns 201', async () => { mockRecordPrice.mockResolvedValue(makeFakePriceRecord()); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/medicine-prices', headers: authHeaders, payload: validBody, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body._id).toBe('pr-1'); expect(body.pricePerUnit).toBe(0.1); }); it('passes householdId and userId to service', async () => { mockRecordPrice.mockResolvedValue(makeFakePriceRecord()); await app.inject({ method: 'POST', url: '/api/v1/households/hh1/medicine-prices', headers: authHeaders, payload: validBody, }); expect(mockRecordPrice).toHaveBeenCalledWith( expect.objectContaining({ medicineProductId: 'mp-1' }), 'hh1', 'kc-1', ); }); it('includes notes in response when present', async () => { mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ notes: 'insurance price' })); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/medicine-prices', headers: authHeaders, payload: validBody, }); expect(res.statusCode).toBe(201); expect(res.json().notes).toBe('insurance price'); }); it('returns 400 for missing required fields', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/medicine-prices', headers: authHeaders, payload: { price: 10 }, }); expect(res.statusCode).toBe(400); }); it('handles Date objects in response', async () => { mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ _id: { toString: () => 'pr-obj' }, date: new Date('2026-01-15T00:00:00.000Z'), createdAt: new Date('2026-01-15T00:00:00.000Z'), })); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/medicine-prices', headers: authHeaders, payload: validBody, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body._id).toBe('pr-obj'); expect(body.date).toBe('2026-01-15T00:00:00.000Z'); }); }); describe('GET /api/v1/households/:householdId/medicine-prices/history/:medicineId', () => { it('returns paginated price history', async () => { mockGetPriceHistory.mockResolvedValue({ data: [makeFakePriceRecord()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/medicine-prices/history/med-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.pagination.hasMore).toBe(false); }); it('passes query params to service', async () => { mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/medicine-prices/history/med-1?storeId=st-1&limit=10', headers: authHeaders, }); expect(mockGetPriceHistory).toHaveBeenCalledWith( 'hh1', 'med-1', expect.objectContaining({ storeId: 'st-1', limit: 10 }), ); }); }); describe('GET /api/v1/households/:householdId/medicine-prices/compare/:medicineId', () => { it('returns store comparison', async () => { mockCompareStores.mockResolvedValue([ { storeId: 'st-1', storeName: 'Walgreens', latestPrice: 10, latestPricePerUnit: 0.1, currency: 'USD', date: new Date('2026-01-15T00:00:00.000Z'), isInsurancePrice: false, }, ]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/medicine-prices/compare/med-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].storeId).toBe('st-1'); expect(body.data[0].date).toBe('2026-01-15T00:00:00.000Z'); }); it('passes householdId and medicineId to service', async () => { mockCompareStores.mockResolvedValue([]); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/medicine-prices/compare/med-99', headers: authHeaders, }); expect(mockCompareStores).toHaveBeenCalledWith('hh1', 'med-99'); }); }); describe('GET /api/v1/households/:householdId/medicine-prices/analytics', () => { it('returns analytics', async () => { mockGetAnalytics.mockResolvedValue({ spendingOverTime: [{ period: '2026-01', total: 50 }], topBySpending: [], spendingByStore: [], priceAlerts: [], }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/medicine-prices/analytics', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.spendingOverTime).toHaveLength(1); expect(body.spendingOverTime[0].total).toBe(50); }); it('passes period query param to service', async () => { mockGetAnalytics.mockResolvedValue({ spendingOverTime: [], topBySpending: [], spendingByStore: [], priceAlerts: [], }); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/medicine-prices/analytics?period=year', headers: authHeaders, }); expect(mockGetAnalytics).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ period: 'year' }), ); }); }); });