project-nomad/admin/tests/unit/drug_labels.spec.ts
caweis 762a4a12ad
Add offline FDA drug reference (labels, interaction view, conditions, remedies) (#1040)
* feat(drug-reference): offline FDA drug labels, conditions, and remedies

Adds an offline medical-reference feature with three coupled layers:

- Drug Reference: full-text search over openFDA drug-label indications,
  a detail page per label, and a side-by-side single-drug comparison view.
  A two-phase background pipeline downloads the openFDA label parts to the
  storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
  that maps each situation to matching OTC drugs, each linking back to its
  Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
  US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
  with their source links and the same safety disclaimers as the rest of the
  feature.

The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.

Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.

Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
  /api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
  concurrency 1. The two drug queues get a per-queue stall override
  (lockDuration 1_800_000, maxStalledCount 3) because each part is one long
  stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
  icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
  job to stream the label JSON out of the downloaded zips.

The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.

The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(drug-reference): drop a fork-internal comment reference

* fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs

- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the
  deferred createTable (was silently swallowed, index never created)
- controller: add remedies:[] to the index() error fallback (required prop)
- strip fork-internal issue/spec references from ported comments
- tsconfig: exclude tests/standalone (node --experimental-strip-types only)
- correct the varchar(768) byte-math comment; extend remedy-spine test

* fix(drug-reference): make the interaction comparison readable at five drugs

The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.

Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(collections): route a 'dataset' tier resource to the drug pipeline

Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.

Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): rework into an opt-in medicine-standard tier dataset

Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.

- install-state: the ingest writes an installed_resources 'dataset' row on
  ready (version = the openFDA export_date), threaded installer to download to
  ingest; the tier-status math and the home-tile gate read it. Manual ingests
  write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
  the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
  the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
  and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
  totalBytes} shape as one drug-data card in the Active Downloads aggregator with
  cancel/remove; the heavy ingest stays in the IngestStatus surface with an
  Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
  re-downloads when newer, gated on installed + no active job.

typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(downloads): keep the drug download card live across all parts

The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance

Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.

Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drug-reference): fold dataset freshness into the content-auto-update path

The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.

Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.

Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds

Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag

Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.

Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
2026-08-04 03:38:33 +00:00

353 lines
13 KiB
TypeScript

import { test } from '@japa/runner'
import {
mapDrugLabelRecord,
normalizeDrugName,
parseDrugLabelManifest,
type OpenFdaLabelRecord,
} from '../../util/drug_labels.js'
// ─── mapDrugLabelRecord ───────────────────────────────────────────────────────
test.group('mapDrugLabelRecord', () => {
// Case 1: Full Rx record → all fields mapped; sections flattened with \n\n.
test('maps a full Rx record with all fields present', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'abc-123',
id: 'rev-001',
version: '3',
effective_time: '20240115',
openfda: {
brand_name: ['Xarelto'],
generic_name: ['rivaroxaban'],
manufacturer_name: ['Janssen Pharmaceuticals, Inc.'],
product_ndc: ['50458-578-03'],
route: ['ORAL'],
product_type: ['HUMAN PRESCRIPTION DRUG'],
},
indications_and_usage: ['Indicated for treatment of DVT.', 'Also for PE.'],
dosage_and_administration: ['15 mg once daily with food.'],
warnings: ['Premature discontinuation increases risk of thrombotic events.'],
boxed_warning: ['PREMATURE DISCONTINUATION OF ANTICOAGULANTS MAY CAUSE THROMBOSIS.'],
drug_interactions: ['Avoid use with combined P-gp and CYP3A4 inhibitors.'],
contraindications: ['Active pathological bleeding.'],
when_using: [],
stop_use: [],
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.equal(row!.set_id, 'abc-123')
assert.equal(row!.spl_id, 'rev-001')
assert.equal(row!.version, '3')
assert.equal(row!.brand_name, 'Xarelto')
assert.equal(row!.generic_name, 'rivaroxaban')
assert.equal(row!.manufacturer, 'Janssen Pharmaceuticals, Inc.')
assert.equal(row!.product_ndc, '50458-578-03')
assert.equal(row!.route, 'ORAL')
assert.equal(row!.product_type, 'HUMAN PRESCRIPTION DRUG')
assert.equal(row!.source_updated_at, '2024-01-15')
// Sections flattened with \n\n
assert.equal(row!.indications, 'Indicated for treatment of DVT.\n\nAlso for PE.')
assert.equal(row!.dosage, '15 mg once daily with food.')
assert.isNotNull(row!.boxed_warning)
assert.isNotNull(row!.drug_interactions)
assert.isNotNull(row!.contraindications)
// Empty arrays → null
assert.isNull(row!.when_using)
assert.isNull(row!.stop_use)
})
// Case 2: OTC record with when_using/stop_use, absent drug_interactions/contraindications/boxed_warning
test('maps an OTC record with absent Rx sections — no throw', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'otc-set-456',
openfda: {
brand_name: ['Tylenol'],
generic_name: ['acetaminophen'],
product_type: ['HUMAN OTC DRUG'],
route: ['ORAL'],
},
indications_and_usage: ['For temporary relief of minor pain.'],
when_using: ['Do not use with other acetaminophen products.'],
stop_use: ['Stop use if symptoms persist more than 3 days.'],
// drug_interactions, contraindications, boxed_warning intentionally absent
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.isNull(row!.drug_interactions)
assert.isNull(row!.contraindications)
assert.isNull(row!.boxed_warning)
assert.isNotNull(row!.when_using)
assert.isNotNull(row!.stop_use)
assert.equal(row!.product_type, 'HUMAN OTC DRUG')
})
// Case 3: Missing set_id → returns null
test('returns null when set_id is missing', ({ assert }) => {
const record: OpenFdaLabelRecord = {
id: 'rev-xyz',
openfda: { brand_name: ['Advil'] },
}
assert.isNull(mapDrugLabelRecord(record))
})
test('returns null when set_id is empty string', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: '', openfda: { brand_name: ['Advil'] } }
assert.isNull(mapDrugLabelRecord(record))
})
// Case 4: Multi-element generic_name/product_ndc/route → joined with ", "
test('joins multi-element generic_name, product_ndc, route with comma', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'multi-001',
openfda: {
generic_name: ['amoxicillin', 'clavulanate potassium'],
product_ndc: ['0069-0070-41', '0069-0070-83'],
route: ['ORAL', 'SUBLINGUAL'],
},
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.equal(row!.generic_name, 'amoxicillin, clavulanate potassium')
assert.equal(row!.product_ndc, '0069-0070-41, 0069-0070-83')
assert.equal(row!.route, 'ORAL, SUBLINGUAL')
})
// Case 5: Multi-element brand_name → first element only
test('takes only the first element of brand_name', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'brand-multi-001',
openfda: { brand_name: ['Augmentin', 'Amoxicillin-Clavulanate'] },
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.equal(row!.brand_name, 'Augmentin')
})
// Case 6: effective_time parsing
test('parses effective_time YYYYMMDD to YYYY-MM-DD', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: 's1', effective_time: '20240115' }
assert.equal(mapDrugLabelRecord(record)!.source_updated_at, '2024-01-15')
})
test('returns null source_updated_at for garbage effective_time', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: 's2', effective_time: 'garbage' }
assert.isNull(mapDrugLabelRecord(record)!.source_updated_at)
})
test('returns null source_updated_at when effective_time is missing', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: 's3' }
assert.isNull(mapDrugLabelRecord(record)!.source_updated_at)
})
// Case 7: Empty-array section → null (not "")
test('empty section array returns null, not empty string', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 's4',
indications_and_usage: [],
warnings: [],
}
const row = mapDrugLabelRecord(record)!
assert.isNull(row.indications)
assert.isNull(row.warnings)
})
// Case 8: product_type handling
test('takes product_type from openfda array first element', ({ assert }) => {
const r1: OpenFdaLabelRecord = {
set_id: 's5',
openfda: { product_type: ['HUMAN OTC DRUG'] },
}
assert.equal(mapDrugLabelRecord(r1)!.product_type, 'HUMAN OTC DRUG')
const r2: OpenFdaLabelRecord = { set_id: 's6' }
assert.isNull(mapDrugLabelRecord(r2)!.product_type)
})
// Case 9: Section text with embedded whitespace → trimmed, joined properly
test('trims and joins sections with embedded whitespace', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 's7',
warnings: [' Risk of bleeding. ', '\n\nDo not crush.\n'],
}
const row = mapDrugLabelRecord(record)!
// The two elements are joined with \n\n, then the whole result trimmed.
// Individual elements may have internal whitespace but the join/trim
// removes leading/trailing blank lines from the whole blob.
assert.isNotNull(row.warnings)
assert.isFalse(row.warnings!.startsWith('\n'))
assert.isFalse(row.warnings!.endsWith('\n'))
assert.include(row.warnings!, ' Risk of bleeding. \n\n\n\nDo not crush.')
})
// Case 10: over-long set_id (> 64) → null (the idempotency key can't be truncated)
test('returns null when set_id exceeds the 64-char key width', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'x'.repeat(65),
openfda: { brand_name: ['Foo'] },
}
assert.isNull(mapDrugLabelRecord(record))
})
// Case 11: a generic_name join longer than the column width is clamped to fit
test('clamps an over-long generic_name to the column width (512)', ({ assert }) => {
// A combination product can join many active ingredients well past 512 chars.
const manyActives = Array.from({ length: 60 }, (_, i) => `ingredient-number-${i}`)
const record: OpenFdaLabelRecord = {
set_id: 'combo-1',
openfda: { brand_name: ['MegaVitamin'], generic_name: manyActives },
}
const row = mapDrugLabelRecord(record)!
assert.isNotNull(row.generic_name)
assert.isTrue(row.generic_name!.length <= 512)
assert.isTrue(row.searchable_name!.length <= 768)
})
})
// ─── normalizeDrugName ────────────────────────────────────────────────────────
test.group('normalizeDrugName', () => {
// Case 1
test('basic brand + generic combination', ({ assert }) => {
assert.equal(normalizeDrugName('Tylenol', 'acetaminophen'), 'tylenol acetaminophen')
})
// Case 2
test('multi-word brand + generic', ({ assert }) => {
assert.equal(
normalizeDrugName('Tylenol Extra Strength', 'acetaminophen'),
'tylenol extra strength acetaminophen'
)
})
// Case 3: Duplicate tokens deduped
test('deduplicates tokens preserving order', ({ assert }) => {
assert.equal(normalizeDrugName('Silicea', 'SILICEA'), 'silicea')
})
// Case 4: Punctuation stripped to spaces
test('strips punctuation to spaces', ({ assert }) => {
assert.equal(normalizeDrugName('Co-Q10 (50 mg)', null), 'co q10 50 mg')
})
// Case 5: Both null/empty → null
test('returns null when both inputs are null', ({ assert }) => {
assert.isNull(normalizeDrugName(null, null))
})
test('returns null when both inputs are empty strings', ({ assert }) => {
assert.isNull(normalizeDrugName('', ''))
})
// Case 6: Extra whitespace collapsed
test('collapses extra whitespace', ({ assert }) => {
assert.equal(normalizeDrugName(' Advil PM ', 'ibuprofen'), 'advil pm ibuprofen')
})
// Edge: one null
test('handles null brand with non-null generic', ({ assert }) => {
assert.equal(normalizeDrugName(null, 'ibuprofen'), 'ibuprofen')
})
test('handles non-null brand with null generic', ({ assert }) => {
assert.equal(normalizeDrugName('Advil', null), 'advil')
})
})
// ─── parseDrugLabelManifest ───────────────────────────────────────────────────
test.group('parseDrugLabelManifest', () => {
const wellFormed = {
meta: { disclaimer: 'Do not rely...' },
results: {
drug: {
label: {
export_date: '2026-06-06',
total_records: 258914,
partitions: [
{
display_name: '/drug/label (part 1 of 13)',
file: 'https://download.open.fda.gov/drug/label/drug-label-0001-of-0013.json.zip',
size_mb: '128.11',
records: 20000,
},
{
display_name: '/drug/label (part 2 of 13)',
file: 'https://download.open.fda.gov/drug/label/drug-label-0002-of-0013.json.zip',
size_mb: '143.09',
records: 20000,
},
],
},
},
},
}
// Well-formed → typed object
test('parses a well-formed manifest into a typed object', ({ assert }) => {
const result = parseDrugLabelManifest(wellFormed)
assert.equal(result.export_date, '2026-06-06')
assert.equal(result.total_records, 258914)
assert.lengthOf(result.partitions, 2)
assert.equal(
result.partitions[0].file,
'https://download.open.fda.gov/drug/label/drug-label-0001-of-0013.json.zip'
)
assert.equal(result.partitions[0].records, 20000)
assert.equal(result.partitions[1].size_mb, '143.09')
})
// Missing results.drug.label → throws
test('throws when results.drug.label is missing', ({ assert }) => {
const bad = { results: { drug: {} } }
assert.throws(() => parseDrugLabelManifest(bad), /Unexpected FDA manifest format/)
})
test('throws when results is missing entirely', ({ assert }) => {
assert.throws(() => parseDrugLabelManifest({}), /Unexpected FDA manifest format/)
})
// Partition missing file → skipped
test('skips partitions that are missing the file field', ({ assert }) => {
const partial = {
results: {
drug: {
label: {
export_date: '2026-06-06',
total_records: 100,
partitions: [
{ display_name: 'bad', size_mb: '10', records: 5 }, // no file
{
display_name: 'good',
file: 'https://example.com/part.zip',
size_mb: '10',
records: 5,
},
],
},
},
},
}
const result = parseDrugLabelManifest(partial)
assert.lengthOf(result.partitions, 1)
assert.equal(result.partitions[0].file, 'https://example.com/part.zip')
})
// All partitions invalid → throws
test('throws when all partitions lack a file field', ({ assert }) => {
const allBad = {
results: {
drug: {
label: {
export_date: '2026-06-06',
total_records: 100,
partitions: [{ display_name: 'bad', records: 5 }],
},
},
},
}
assert.throws(() => parseDrugLabelManifest(allBad), /all partitions were invalid/)
})
})