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 { mockGetAlerts, mockCreateList, mockList, mockGetById, mockUpdateList, mockUpdateItem, mockAddToCabinet, mockGetStoreComparison, } = vi.hoisted(() => ({ mockGetAlerts: vi.fn(), mockCreateList: vi.fn(), mockList: vi.fn(), mockGetById: vi.fn(), mockUpdateList: vi.fn(), mockUpdateItem: vi.fn(), mockAddToCabinet: vi.fn(), mockGetStoreComparison: vi.fn(), })); vi.mock('./refills.repository.js', () => ({ RefillsRepository: class { create = vi.fn(); findByHousehold = vi.fn(); findById = vi.fn(); update = vi.fn(); updateItem = vi.fn(); markItemsAddedToCabinet = vi.fn(); }, })); vi.mock('./refills.service.js', () => ({ RefillsService: class { getAlerts = mockGetAlerts; createList = mockCreateList; list = mockList; getById = mockGetById; updateList = mockUpdateList; updateItem = mockUpdateItem; addToCabinet = mockAddToCabinet; getStoreComparison = mockGetStoreComparison; }, })); 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 refillsRoutes from './refills.routes.js'; function makeFakeRefillList(overrides = {}) { return { _id: 'rl-1', householdId: 'hh1', name: 'Monthly Refills', items: [], status: 'active', createdBy: 'kc-1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...overrides, }; } describe('refills.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(refillsRoutes); 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/refills/alerts', () => { it('returns alerts with price options', async () => { mockGetAlerts.mockResolvedValue([ { medicineId: 'med-1', medicineName: 'Aspirin', medicineStrength: 500, medicineStrengthUnit: 'mg', daysUntilEmpty: 3, 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'), }, }, ]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/alerts', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].lastKnownPrice.storeName).toBe('CVS'); expect(body.data[0].lastKnownPrice.date).toBe('2026-01-01T00:00:00.000Z'); expect(body.data[0].cheapestOption.storeName).toBe('Walmart'); }); it('returns alerts', async () => { mockGetAlerts.mockResolvedValue([ { medicineId: 'med-1', medicineName: 'Aspirin', medicineStrength: 500, medicineStrengthUnit: 'mg', daysUntilEmpty: 3, dailyConsumption: 2, currentStock: 6, suggestedQuantity: 60, }, ]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/alerts', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].medicineName).toBe('Aspirin'); }); it('uses requesting user by default', async () => { mockGetAlerts.mockResolvedValue([]); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/alerts', headers: authHeaders, }); expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'kc-1', 7); }); it('uses userId query param when provided', async () => { mockGetAlerts.mockResolvedValue([]); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/alerts?userId=other-user&thresholdDays=14', headers: authHeaders, }); expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'other-user', 14); }); }); describe('POST /api/v1/households/:householdId/refills/lists', () => { it('creates list and returns 201', async () => { mockCreateList.mockResolvedValue(makeFakeRefillList()); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/refills/lists', headers: authHeaders, payload: { name: 'Monthly Refills', fromAlerts: false, thresholdDays: 7 }, }); expect(res.statusCode).toBe(201); expect(res.json().name).toBe('Monthly Refills'); }); it('passes householdId and userId to service', async () => { mockCreateList.mockResolvedValue(makeFakeRefillList()); await app.inject({ method: 'POST', url: '/api/v1/households/hh1/refills/lists', headers: authHeaders, payload: { name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, }); expect(mockCreateList).toHaveBeenCalledWith( expect.objectContaining({ name: 'Auto List', fromAlerts: true }), 'hh1', 'kc-1', ); }); it('returns 400 for missing name', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/refills/lists', headers: authHeaders, payload: {}, }); expect(res.statusCode).toBe(400); }); }); describe('GET /api/v1/households/:householdId/refills/lists', () => { it('returns paginated lists', async () => { mockList.mockResolvedValue({ data: [makeFakeRefillList()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.pagination.hasMore).toBe(false); }); 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', }, ], }), ], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data[0].preferredStoreId).toBe('st-1'); expect(body.data[0].totalEstimatedCost).toBe(25.5); const item = body.data[0].items[0]; expect(item.estimatedPrice).toBe(10); expect(item.actualPrice).toBe(9.5); expect(item.checkedAt).toBe('2026-01-10T00:00:00.000Z'); expect(item.storeId).toBe('st-1'); expect(item.notes).toBe('generic brand'); }); }); describe('GET /api/v1/households/:householdId/refills/lists/:id', () => { it('returns single list', async () => { mockGetById.mockResolvedValue(makeFakeRefillList()); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists/rl-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().name).toBe('Monthly Refills'); }); 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, }, ], }), ); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists/rl-1', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body._id).toBe('rl-obj'); expect(body.items[0]._id).toBe('item-obj'); expect(body.createdAt).toBe('2026-01-01T00:00:00.000Z'); }); it('passes id and householdId to service', async () => { mockGetById.mockResolvedValue(makeFakeRefillList()); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists/rl-99', headers: authHeaders, }); expect(mockGetById).toHaveBeenCalledWith('rl-99', 'hh1'); }); }); describe('PATCH /api/v1/households/:householdId/refills/lists/:id', () => { it('updates list and returns 200', async () => { mockUpdateList.mockResolvedValue(makeFakeRefillList({ name: 'Updated' })); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/refills/lists/rl-1', headers: authHeaders, payload: { name: 'Updated' }, }); expect(res.statusCode).toBe(200); expect(res.json().name).toBe('Updated'); }); it('passes id, householdId, body to service', async () => { mockUpdateList.mockResolvedValue(makeFakeRefillList()); await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/refills/lists/rl-1', headers: authHeaders, payload: { status: 'shopping' }, }); expect(mockUpdateList).toHaveBeenCalledWith( 'rl-1', 'hh1', expect.objectContaining({ status: 'shopping' }), ); }); }); describe('PATCH /api/v1/households/:householdId/refills/lists/:id/items/:itemId', () => { it('updates item and returns 200', async () => { mockUpdateItem.mockResolvedValue(makeFakeRefillList()); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-1', headers: authHeaders, payload: { checked: true }, }); expect(res.statusCode).toBe(200); }); it('passes listId, householdId, itemId, body to service', async () => { mockUpdateItem.mockResolvedValue(makeFakeRefillList()); await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-99', headers: authHeaders, payload: { actualPrice: 9.99 }, }); expect(mockUpdateItem).toHaveBeenCalledWith( 'rl-1', 'hh1', 'item-99', expect.objectContaining({ actualPrice: 9.99 }), ); }); }); describe('POST /api/v1/households/:householdId/refills/lists/:id/add-to-cabinet', () => { it('adds items to cabinet and returns summary', async () => { mockAddToCabinet.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 0 }); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.addedCount).toBe(2); expect(body.priceRecordsCreated).toBe(0); }); it('passes listId, householdId, userId to service', async () => { mockAddToCabinet.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 }); await app.inject({ method: 'POST', url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet', headers: authHeaders, }); expect(mockAddToCabinet).toHaveBeenCalledWith('rl-1', 'hh1', 'kc-1'); }); }); describe('GET /api/v1/households/:householdId/refills/lists/:id/store-comparison', () => { it('returns store comparison data', async () => { mockGetStoreComparison.mockResolvedValue([ { medicineId: 'med-1', storeOptions: [ { storeId: 'st-1', storeName: 'CVS', latestPrice: 8, latestPricePerUnit: 0.08, currency: 'USD', date: new Date('2026-01-01T00:00:00.000Z'), isInsurancePrice: false, }, ], }, ]); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists/rl-1/store-comparison', headers: authHeaders, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(1); expect(body.data[0].storeOptions[0].date).toBe('2026-01-01T00:00:00.000Z'); }); it('passes listId and householdId to service', async () => { mockGetStoreComparison.mockResolvedValue([]); await app.inject({ method: 'GET', url: '/api/v1/households/hh1/refills/lists/rl-99/store-comparison', headers: authHeaders, }); expect(mockGetStoreComparison).toHaveBeenCalledWith('rl-99', 'hh1'); }); }); });