import sensible from "@fastify/sensible"; import Fastify, { type FastifyInstance } from "fastify"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { fetchMock, requireAuthMock } = vi.hoisted(() => ({ fetchMock: vi.fn(), requireAuthMock: vi.fn(async () => {}), })); vi.mock("../plugins/auth.js", () => ({ requireAuth: requireAuthMock, })); function jsonResponse(body: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, status, json: async () => body, } as Response; } function createEmaRow(overrides: Partial> = {}): Record { return { category: "Human", medicine_status: "Authorised", name_of_medicine: "Aspirin 500 mg tablets", international_non_proprietary_name_common_name: "Acetylsalicylic acid", active_substance: "Acetylsalicylic acid", marketing_authorisation_developer_applicant_holder: "Bayer", therapeutic_area_mesh: "Pain", therapeutic_indication: "Pain relief", atc_code_human: "N02BA01", generic_or_hybrid: "No", biosimilar: "No", marketing_authorisation_date: "01/02/2024", ema_product_number: "EMA-ASPIRIN", ...overrides, }; } async function buildApp(): Promise { const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js"); const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(medicationEnrichmentRoutes); await app.ready(); return app; } describe("medication enrichment", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); fetchMock.mockReset(); requireAuthMock.mockReset(); requireAuthMock.mockImplementation(async () => {}); vi.stubGlobal("fetch", fetchMock); }); it("normalizes German ingredient queries for EMA-backed search results", async () => { const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve( jsonResponse([ createEmaRow({ name_of_medicine: "Tylenol 500 mg tablets", international_non_proprietary_name_common_name: "Acetaminophen", active_substance: "Acetaminophen", ema_product_number: "EMA-TYLENOL", }), createEmaRow({ name_of_medicine: "Ibuprofen 400 mg tablets", international_non_proprietary_name_common_name: "Ibuprofen", active_substance: "Ibuprofen", ema_product_number: "EMA-IBUPROFEN", }), ]) ); } if (url.includes("/drugs.json?name=")) { return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } })); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve(jsonResponse({ results: [] })); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5); expect(response.normalizedQuery).toBe("paracetamol 500 mg"); expect(response.results).toHaveLength(1); expect(response.results[0]).toMatchObject({ code: "EMA-TYLENOL", name: "Tylenol 500 mg tablets", matchType: "ingredient", source: "ema", }); }); it("requires auth and returns EMA search results from the route", async () => { const app = await buildApp(); fetchMock.mockImplementation((url: string) => { if (url.includes("/drugs.json?name=")) { return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } })); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve(jsonResponse({ results: [] })); } if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve(jsonResponse([createEmaRow()])); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await app.inject({ method: "GET", url: "/medication-enrichment/search?q=aspirin&limit=1", }); expect(response.statusCode).toBe(200); expect(requireAuthMock).toHaveBeenCalledTimes(1); expect(response.json()).toMatchObject({ query: "aspirin", normalizedQuery: "aspirin", hasMore: false, results: [ { code: "EMA-ASPIRIN", name: "Aspirin 500 mg tablets", source: "ema", }, ], }); await app.close(); }); it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => { const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve(jsonResponse([createEmaRow()])); } if (url.includes("/drugs.json?name=semaglutide")) { return Promise.resolve( jsonResponse({ drugGroup: { conceptGroup: [ { tty: "SBD", conceptProperties: [ { rxcui: "12345", name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]", synonym: "Wegovy 0.25 mg oral tablet", }, ], }, ], }, }) ); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve( jsonResponse({ results: [ { product_ndc: "00011-1111", brand_name: "Ozempic", generic_name: "Semaglutide", dosage_form: "Tablet", marketing_start_date: "20240101", packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }], }, ], }) ); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await searchMedicationEnrichment("Semaglutide", 3); expect(response.hasMore).toBe(false); expect(response.results).toEqual( expect.arrayContaining([ expect.objectContaining({ code: "12345", name: "Wegovy", genericName: "Semaglutide", source: "rxnorm", }), expect.objectContaining({ code: "00011-1111", name: "Ozempic", genericName: "Semaglutide", source: "openfda", }), ]) ); expect(response.results.find((result) => result.code === "00011-1111")?.packageOptions).toEqual([ { label: "2 blisters in 1 carton / 10 tablets in 1 blister", description: "2 blisters in 1 carton / 10 tablets in 1 blister", packageType: "blister", packCount: 1, blistersPerPack: 2, pillsPerBlister: 10, totalPills: 20, looseTablets: 0, packageAmountValue: null, packageAmountUnit: null, }, ]); }); it("prioritizes results with package sizes before source-only matches", async () => { const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve(jsonResponse([createEmaRow()])); } if (url.includes("/drugs.json?name=")) { return Promise.resolve( jsonResponse({ drugGroup: { conceptGroup: [ { tty: "SBD", conceptProperties: [ { rxcui: "1191", name: "Aspirin 500 MG Oral Tablet [Aspirin]", synonym: "Aspirin 500 mg oral tablet", }, ], }, ], }, }) ); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve( jsonResponse({ results: [ { product_ndc: "00011-1111", brand_name: "Bayer Aspirin", generic_name: "Acetylsalicylic acid", dosage_form: "Tablet", marketing_start_date: "20240101", packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }], }, ], }) ); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await searchMedicationEnrichment("Aspirin", 3); expect(response.hasMore).toBe(false); expect(response.results).toHaveLength(3); expect(response.results[0]).toMatchObject({ code: "00011-1111", source: "openfda", }); expect(response.results[1]).toMatchObject({ code: "1191", source: "rxnorm", }); expect(response.results[2]).toMatchObject({ code: "EMA-ASPIRIN", source: "ema", }); }); it("sorts richer package hits ahead of package-bearing results with fewer options", async () => { const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve(jsonResponse([createEmaRow()])); } if (url.includes("/drugs.json?name=")) { return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } })); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve( jsonResponse({ results: [ { product_ndc: "00011-1111", brand_name: "Ibuprofen Max", generic_name: "Ibuprofen", dosage_form: "Tablet", marketing_start_date: "20240101", packaging: [{ description: "60 tablets in 1 bottle" }, { description: "120 tablets in 1 bottle" }], }, { product_ndc: "00022-2222", brand_name: "Ibuprofen Compact", generic_name: "Ibuprofen", dosage_form: "Tablet", marketing_start_date: "20240101", packaging: [{ description: "20 tablets in 1 blister" }], }, ], }) ); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await searchMedicationEnrichment("Ibuprofen", 3); expect(response.results.slice(0, 2)).toMatchObject([ { code: "00011-1111", source: "openfda", }, { code: "00022-2222", source: "openfda", }, ]); expect(response.results[0].packageOptions).toHaveLength(2); expect(response.results[1].packageOptions).toHaveLength(1); }); it("validates malformed search requests", async () => { const app = await buildApp(); const response = await app.inject({ method: "GET", url: "/medication-enrichment/search?q=", }); expect(response.statusCode).toBe(400); expect(fetchMock).not.toHaveBeenCalled(); await app.close(); }); it("returns enrichment suggestions with optional RxNorm strength data", async () => { const app = await buildApp(); fetchMock .mockResolvedValueOnce( jsonResponse([ createEmaRow({ name_of_medicine: "Tylenol 500 mg tablets", international_non_proprietary_name_common_name: "Acetaminophen", active_substance: "Acetaminophen", ema_product_number: "EMA-TYLENOL", }), ]) ) .mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } })) .mockResolvedValueOnce( jsonResponse({ relatedGroup: { conceptGroup: [ { conceptProperties: [ { name: "Acetaminophen 500 MG Oral Tablet" }, { name: "Acetaminophen 650 MG Oral Tablet" }, ], }, ], }, }) ); const response = await app.inject({ method: "POST", url: "/medication-enrichment/enrich", payload: { query: "Paracetamol", name: "Tylenol 500 mg tablets", genericName: "Acetaminophen", }, }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ selection: { name: "Tylenol 500 mg tablets", genericName: "Acetaminophen", source: "ema+rxnorm", }, suggestions: { medicationForm: "tablet", strengthOptions: [ { label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }, { label: "650 mg", pillWeightMg: 650, doseUnit: "mg" }, ], }, meta: { rxNormMatched: true, openFdaMatched: false, partial: false, note: null, }, }); await app.close(); }); it("includes package suggestions from openFDA fallback in route responses", async () => { const app = await buildApp(); fetchMock.mockImplementation((url: string) => { if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve( jsonResponse([ createEmaRow({ name_of_medicine: "Tylenol 500 mg tablets", international_non_proprietary_name_common_name: "Acetaminophen", active_substance: "Acetaminophen", ema_product_number: "EMA-TYLENOL", }), ]) ); } if (url.includes("/rxcui.json?name=acetaminophen&search=2")) { return Promise.resolve(jsonResponse({ idGroup: {} })); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve( jsonResponse({ results: [ { product_ndc: "00011-1111", brand_name: "Tylenol", generic_name: "Acetaminophen", dosage_form: "Tablet", active_ingredients: [{ name: "Acetaminophen", strength: "500 mg" }], packaging: [{ description: "30 tablets in 1 bottle" }], }, ], }) ); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await app.inject({ method: "POST", url: "/medication-enrichment/enrich", payload: { query: "Paracetamol", name: "Tylenol 500 mg tablets", genericName: "Acetaminophen", }, }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ selection: { name: "Tylenol 500 mg tablets", genericName: "Acetaminophen", source: "ema+openfda", }, suggestions: { medicationForm: "tablet", strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }], packageOptions: [ { label: "30 tablets in 1 bottle", description: "30 tablets in 1 bottle", packageType: "bottle", packCount: 1, blistersPerPack: null, pillsPerBlister: null, totalPills: 30, looseTablets: 30, packageAmountValue: null, packageAmountUnit: null, }, ], }, meta: { rxNormMatched: false, openFdaMatched: true, partial: false, note: null, }, }); await app.close(); }); it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => { const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("medicines-output-medicines_json-report_en.json")) { return Promise.resolve( jsonResponse([ createEmaRow({ name_of_medicine: "Tylenol 500 mg tablets", international_non_proprietary_name_common_name: "Acetaminophen", active_substance: "Acetaminophen", ema_product_number: "EMA-TYLENOL", }), ]) ); } if (url.includes("/rxcui.json?name=acetaminophen&search=2")) { return Promise.reject(new Error("rxnorm timeout")); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve(jsonResponse({ results: [] })); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await enrichMedicationSelection({ query: "Paracetamol", name: "Tylenol 500 mg tablets", genericName: "Acetaminophen", }); expect(response.selection.source).toBe("ema"); expect(response.suggestions.strengthOptions).toEqual([]); expect(response.meta).toEqual({ rxNormMatched: false, openFdaMatched: false, partial: true, note: "Returned EMA enrichment without RxNorm suggestions.", }); }); it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => { const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("/rxcui/12345/related.json")) { return Promise.resolve( jsonResponse({ relatedGroup: { conceptGroup: [], }, }) ); } if (url.includes("api.fda.gov/drug/ndc.json")) { return Promise.resolve( jsonResponse({ results: [ { product_ndc: "00011-1111", brand_name: "Ozempic", generic_name: "Semaglutide", dosage_form: "Tablet", active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }], }, ], }) ); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await enrichMedicationSelection({ query: "Ozempic", name: "Ozempic", genericName: "Semaglutide", code: "12345", source: "rxnorm", }); expect(response).toMatchObject({ selection: { name: "Ozempic", genericName: "Semaglutide", source: "rxnorm+openfda", }, suggestions: { medicationForm: "tablet", strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }], }, meta: { rxNormMatched: false, openFdaMatched: true, partial: false, note: null, }, }); }); it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => { const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { if (url.includes("search=product_ndc%3A%2200011-1111%22")) { return Promise.resolve( jsonResponse({ results: [ { product_ndc: "00011-1111", brand_name: "US Ibuprofen", generic_name: "Ibuprofen", dosage_form: "Tablet", active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }], packaging: [{ description: "100 mL in 1 bottle" }], }, ], }) ); } if (url.includes("/rxcui.json?name=ibuprofen&search=2")) { return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } })); } if (url.includes("/rxcui/161/related.json")) { return Promise.resolve( jsonResponse({ relatedGroup: { conceptGroup: [ { conceptProperties: [ { name: "Ibuprofen 200 MG Oral Tablet" }, { name: "Ibuprofen 400 MG Oral Tablet" }, ], }, ], }, }) ); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const response = await enrichMedicationSelection({ query: "US Ibuprofen", name: "US Ibuprofen", genericName: "Ibuprofen", code: "00011-1111", source: "openfda", }); expect(response).toMatchObject({ selection: { name: "US Ibuprofen", genericName: "Ibuprofen", source: "rxnorm+openfda", }, suggestions: { medicationForm: "tablet", strengthOptions: [ { label: "200 mg", pillWeightMg: 200, doseUnit: "mg" }, { label: "400 mg", pillWeightMg: 400, doseUnit: "mg" }, ], packageOptions: [ { label: "100 mL in 1 bottle", description: "100 mL in 1 bottle", packageType: "liquid_container", packCount: 1, blistersPerPack: null, pillsPerBlister: null, totalPills: 100, looseTablets: 100, packageAmountValue: 100, packageAmountUnit: "ml", }, ], }, meta: { rxNormMatched: true, openFdaMatched: true, partial: false, note: null, }, }); }); it("returns not found when an explicit selection cannot be resolved", async () => { const app = await buildApp(); fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()])); const response = await app.inject({ method: "POST", url: "/medication-enrichment/enrich", payload: { query: "Unknown", name: "Completely Different Medication", genericName: "No match", }, }); expect(response.statusCode).toBe(404); expect(response.json()).toMatchObject({ code: "MEDICATION_ENRICHMENT_NOT_FOUND", error: "Selected medication could not be resolved.", }); await app.close(); }); it("keeps split module exports aligned with the canonical enrichment service", async () => { const indexExports = await import("../services/medication-enrichment/index.js"); const searchExports = await import("../services/medication-enrichment/search.js"); const adapterExports = await import("../services/medication-enrichment/adapters.js"); const canonical = await import("../services/medication-enrichment.js"); expect(indexExports.searchMedicationEnrichment).toBe(canonical.searchMedicationEnrichment); expect(indexExports.enrichMedicationSelection).toBe(canonical.enrichMedicationSelection); expect(searchExports.searchMedicationEnrichment).toBe(canonical.searchMedicationEnrichment); expect(adapterExports.MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT).toBe( canonical.MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT ); expect(adapterExports.MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT).toBe( canonical.MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT ); }); it("returns transport-safe 503 payload when search lookup fails unexpectedly", async () => { const app = await buildApp(); fetchMock.mockRejectedValue(new Error("network unavailable")); const response = await app.inject({ method: "GET", url: "/medication-enrichment/search?q=aspirin&limit=1", }); expect(response.statusCode).toBe(503); expect(response.json()).toEqual({ error: "Medication enrichment is temporarily unavailable.", code: "MEDICATION_ENRICHMENT_UNAVAILABLE", }); await app.close(); }); });