Repository: cancervariants/therapy-normalization (language: python)

Issue:
Issue 204
Fixes #204 

* Use latest available version of all source data
* Refactor ETL classes to standardize workflow and use shared methods where possible
* Use ISO8061 date format for versions
* Port RxNorm download script into Python
* Retrieve HemOnc data from Harvard Dataverse using API key


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index e7a7bd52..00000000
--- a/.coveragerc
+++ /dev/null
@@ -1,7 +0,0 @@
-[run]
-source =
-   metakb/
-   tests/
-omit =
-   setup.py
-   docs/*
\ No newline at end of file
diff --git a/.flake8 b/.flake8
index cee4ee88..42ad32f3 100644
--- a/.flake8
+++ b/.flake8
@@ -1,5 +1,6 @@
 [flake8]
-ignore = D205, D400
+ignore = D205, D400, I101, ANN101, ANN002, ANN003
+max-line-length = 88
 exclude =
   .git
   venv
@@ -7,4 +8,15 @@ exclude =
   source
   outputs
   docs/*
+  analysis/*
+  codebuild/*
   therapy/version.py
+inline-quotes = "
+import-order-style = pep8
+application-import-names =
+  therapy
+  tests
+per-file-ignores =
+  tests/*:ANN001, ANN2
+  *__init__.py:F401
+mypy-init-return = true
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index 4d834b47..8432261e 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -8,7 +8,7 @@ jobs:
             AWS_SECRET_ACCESS_KEY: ${{ secrets.DUMMY_AWS_SECRET_ACCESS_KEY }}
             AWS_DEFAULT_REGION: us-east-2
             AWS_DEFAULT_OUTPUT: text
-            THERAPY_NORM_DB_URL: http://localhost:8000
+            THERAPY_NORM_DB_URL: http://localhost:8001
             TEST: THERAPY_TEST
         steps:
             - uses: actions/checkout@v2
@@ -25,12 +25,14 @@ jobs:
 
             - name: Build local DynamoDB
               run: |
-                  chmod +x ./tests/unit/dynamodb_build.bash
-                  ./tests/unit/dynamodb_build.bash
+                  chmod +x ./tests/scripts/dynamodb_build.bash
+                  ./tests/scripts/dynamodb_build.bash
 
-            - name: Load and Test DynamoDB
-              run: |
-                  pipenv run pytest tests/unit/test_database.py
+            - name: Check style
+              run: pipenv run tox -e py38-lint
+
+            - name: Check type correctness
+              run: pipenv run tox -e py38-mypy
 
-            - run: pipenv run flake8
-            - run: pipenv run pytest tests/
+            - name: Run tests
+              run: pipenv run tox -e githubactions
diff --git a/.gitignore b/.gitignore
index a51eb5a6..baab3e6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,7 +98,7 @@ ipython_config.py
 #   However, in case of collaboration, if having platform-specific dependencies or dependencies
 #   having no cross-platform support, pipenv may install dependencies that don't work, or not
 #   install all needed dependencies.
-#Pipfile.lock
+Pipfile.lock
 
 # PEP 582; used by e.g. github.com/David-OConnor/pyflow
 __pypackages__/
@@ -146,18 +146,15 @@ cython_debug/
 # allow pytest override with pytest.ini
 pytest.ini
 
-# ignore data dir
+# data directories
 data/*
 therapy/data/
 
+# dynamodb
+dynamodb_local_latest/*
+tests/scripts/dynamodb_local/*
+
 # IDE materials
 .idea/
 .vim/
 *.swp
-
-# DynamoDB Local
-dynamodb_local_latest/*
-
-# Build files
-Pipfile.lock
-pyproject.toml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4c7e7618..a1b72af1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,6 +5,6 @@ repos:
     rev: v1.4.0
     hooks:
     - id: flake8
-      additional_dependencies: [flake8-docstrings]
+      additional_dependencies: [flake8-docstrings, flake8-quotes, flake8-import-order, flake8-annotations]
     - id: check-added-large-files
     - id: detect-private-key
diff --git a/EBSampleApp-Python.iml b/EBSampleApp-Python.iml
deleted file mode 100644
index db04778c..00000000
--- a/EBSampleApp-Python.iml
+++ /dev/null
@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<module type="PYTHON_MODULE" version="4">
-  <component name="GoLibraries">
-    <option name="urls">
-      <set>
-        <option value="file://$MODULE_DIR$" />
-      </set>
-    </option>
-  </component>
-  <component name="NewModuleRootManager" inherit-compiler-output="true">
-    <exclude-output />
-    <content url="file://$MODULE_DIR$">
-      <excludeFolder url="file://$MODULE_DIR$/build" />
-    </content>
-    <orderEntry type="jdk" jdkName="Python 3.6 (NEWRELEASE)" jdkType="Python SDK" />
-    <orderEntry type="sourceFolder" forTests="false" />
-  </component>
-</module>
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..819034e3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+.PHONY: clean, run
+
+run:
+	uvicorn therapy.main:app --reload --port=$(or $(port), 8000)
+
+reqs: Pipfile
+	pipenv lock --requirements > requirements.txt
+	pipenv lock --dev --requirements > requirements-dev.txt
+
+clean:
+	@find . -type f -name '*.pyc' -delete
+	@find . -type d -name '__pycache__' | xargs rm -rf
+	@rm -rf build/
+	@rm -rf dist/
+	@rm -f src/*.egg*
diff --git a/Pipfile b/Pipfile
index 8c8f6666..90b564fb 100644
--- a/Pipfile
+++ b/Pipfile
@@ -3,21 +3,8 @@ name = "pypi"
 url = "https://pypi.org/simple"
 verify_ssl = true
 
-[dev-packages]
-pytest = "*"
-pre-commit = "*"
-flake8 = "*"
-flake8-docstrings = "*"
-coverage = "*"
-pytest-cov = "*"
-jupyterlab = "*"
-thera-py = {editable = true, path = "."}
-coveralls = "*"
-civicpy = "*"
-
 [packages]
 pydantic = ">=1.8.2"
-twine = "*"
 fastapi = ">=0.65.2"
 urllib3 = ">=1.26.5"
 uvicorn = "*"
@@ -26,6 +13,7 @@ lxml = ">=4.6.3"
 boto3 = "*"
 cython = "*"
 owlready2 = "*"
+pyparsing = "==2.4.7"
 rdflib = "*"
 uvloop = "*"
 websockets = ">=9.1"
@@ -35,5 +23,25 @@ wikibaseintegrator = "==0.11.0"
 ipykernel = "*"
 bs4 = "*"
 pyyaml = "*"
-disease-normalizer = ">=0.2.10"
-"ga4gh.vrsatile.pydantic" = ">=0.0.3"
+disease-normalizer = ">=0.2.11"
+"ga4gh.vrsatile.pydantic" = ">=0.0.5"
+chembl-downloader = "*"
+bioversions = ">=0.4.3"
+pystow = "*"
+
+[dev-packages]
+thera-py = {editable = true, path = "."}
+pytest = "*"
+pytest-cov = "*"
+pre-commit = "*"
+flake8 = "*"
+flake8-docstrings = "*"
+flake8-quotes = "*"
+flake8-import-order = "*"
+flake8-annotations = "*"
+jupyterlab = "*"
+civicpy = "*"
+mypy = "*"
+types-requests = "*"
+types-pyyaml = "*"
+tox = "*"
diff --git a/README.md b/README.md
index b375c32e..ee260d0c 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,9 @@ pipenv sync
 
 ### Deploying DynamoDB Locally
 
-We use Amazon DynamoDB for our database. To deploy locally, follow [these instructions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html).
+We use Amazon DynamoDB for data storage. To deploy locally, follow [these instructions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html).
 
-### Init coding style tests
+### Initialize development environment
 
 Code style is managed by [flake8](https://github.com/PyCQA/flake8) and checked prior to commit.
 
@@ -27,26 +27,32 @@ We use [pre-commit](https://pre-commit.com/#usage) to run conformance tests.
 
 This ensures:
 
-* Check code style
-* Check for added large files
-* Detect AWS Credentials
-* Detect Private Key
+* Style correctness
+* No large files
+* AWS credentials are present
+* Private key is present
 
-Before first commit run:
+Pre-commit *must* be installed before your first commit. Use the following command:
 
 ```commandline
 pre-commit install
 ```
 
-### Running unit tests
+### Running tests
 
-Running unit tests is as easy as pytest.
+Unit tests are run with pytest.
 
 ```commandline
 pipenv run pytest
 ```
 
-### Updating the therapy normalization database
+We also provide [Tox](https://tox.wiki/en/latest/index.html) settings to test in multiple environments and check for proper type annotations and code style. If interpreters for Python 3.8 and Python 3.9 are present, the following will run all tests for all environments:
+
+```commandline
+tox
+```
+
+### Updating the database
 
 Before you use the CLI to update the database, run the following in a separate terminal to start DynamoDB on `port 8000`:
 
@@ -63,8 +69,23 @@ You must set the `RxNORM_API_KEY` environment variable to your API key. This can
 export RXNORM_API_KEY={rxnorm_api_key}
 ```
 
+HemOnc.org data requires a Harvard Dataverse API key. After creating a user account on the Harvard Dataverse website, you can follow [these instructions](https://guides.dataverse.org/en/latest/user/account.html) to generate a key. Once you have a key, set the following environment variable:
+
+```shell script
+export DATAVERSE_API_KEY={your api key}
+```
+
 #### Update source(s)
-The Therapy Normalizer currently aggregates therapy data from [ChEMBL](https://www.ebi.ac.uk/chembl/), [NCIt](https://ncithesaurus.nci.nih.gov/ncitbrowser/), [RxNorm](https://www.nlm.nih.gov/research/umls/rxnorm/index.html), [DrugBank](https://go.drugbank.com/) (currently using CC0 data only), [ChemIDPlus](https://chem.nlm.nih.gov/chemidplus/), [Wikidata](https://www.wikidata.org/wiki/Wikidata:Main_Page), and [HemOnc.org](https://hemonc.org/wiki/Main_Page).
+The Therapy Normalizer currently aggregates therapy data from:
+* [ChEMBL](https://www.ebi.ac.uk/chembl/)
+* [ChemIDPlus](https://chem.nlm.nih.gov/chemidplus/)
+* [DrugBank](https://go.drugbank.com/) (using CC0 data only)
+* [Drugs@FDA](https://www.accessdata.fda.gov/scripts/cder/daf/)
+* [The IUPHAR/BPS Guide to PHARMACOLOGY](https://www.guidetopharmacology.org/)
+* [HemOnc.org](https://hemonc.org/wiki/Main_Page) (using CC-BY data only).
+* [The National Cancer Institute Thesaurus](https://ncithesaurus.nci.nih.gov/ncitbrowser/)
+* [RxNorm](https://www.nlm.nih.gov/research/umls/rxnorm/index.html)
+* [Wikidata](https://www.wikidata.org/wiki/Wikidata:Main_Page)
 
 To update source(s), simply set `--normalizer` to the source(s) you wish to update separated by spaces. For example, the following command updates ChEMBL and Wikidata:
 
@@ -78,7 +99,7 @@ You can update all sources at once with the `--update_all` flag:
 python3 -m therapy.cli --update_all
 ```
 
-The `data/` subdirectory within the application should include all source data. The normalizer is capable of acquiring most of these files automatically; the exception is the HemOnc.org data, which must be manually downloaded from the [Harvard Dataverse](https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/9CY9C6) and placed within the `data/hemonc` subdirectory. Files for all sources should follow the naming convention demonstrated below (with version numbers/dates changed where applicable).
+The `data/` subdirectory within the package source should house all desired input data. Files for all sources should follow the naming convention demonstrated below (with version numbers/dates changed where applicable).
 
 ```
 therapy/data
@@ -88,6 +109,9 @@ therapy/data
 │   └── chemidplus_20200327.xml
 ├── drugbank
 │   └── drugbank_5.1.8.csv
+├── guidetopharmacology
+│   ├── guidetopharmacology_ligand_id_mapping_2021.3.tsv
+│   └── guidetopharmacology_ligands_2021.3.tsv
 ├── hemonc
 │   ├── hemonc_concepts_20210225.csv
 │   ├── hemonc_rels_20210225.csv
@@ -103,7 +127,6 @@ therapy/data
 
 Updates to the HemOnc source depend on the [Disease Normalizer](https://github.com/cancervariants/disease-normalization) service. If the Disease Normalizer database appears to be empty or incomplete, updates to HemOnc will also trigger a refresh of the Disease Normalizer database. See its README for additional data requirements.
 
-
 ### Create Merged Concept Groups
 The `/normalize` endpoint relies on merged concept groups.  The `--update_merged` flag generates these groups:
 
diff --git a/Precision oncology coding systems.xlsx b/analysis/Precision oncology coding systems.xlsx
similarity index 100%
rename from Precision oncology coding systems.xlsx
rename to analysis/Precision oncology coding systems.xlsx
diff --git a/analysis/drugsatfda.ipynb b/analysis/drugsatfda.ipynb
new file mode 100644
index 00000000..c4e6bc12
--- /dev/null
+++ b/analysis/drugsatfda.ipynb
@@ -0,0 +1,241 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "premier-airport",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import json\n",
+    "from pathlib import Path\n",
+    "\n",
+    "path = Path('/Users/jss009/code/therapy-normalization/therapy/data/drugsatfda/drugsatfda_20210817.json')\n",
+    "data = json.load(open(path, 'r'))['results']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "potential-wages",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "***Using Therapy Database Endpoint: http://localhost:8000***\n"
+     ]
+    }
+   ],
+   "source": [
+    "from therapy.query import QueryHandler\n",
+    "q = QueryHandler()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "unlimited-oxide",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Total drugs: 25017\n",
+      "# with conflicting statuses: 520\n",
+      "# with no `product` property: 274\n"
+     ]
+    }
+   ],
+   "source": [
+    "conflict_status = []\n",
+    "no_products = []\n",
+    "valid = []\n",
+    "for r in data:\n",
+    "    try:\n",
+    "        products = r['products']\n",
+    "    except KeyError:\n",
+    "        no_products.append(r)\n",
+    "        continue\n",
+    "    if not all([p['marketing_status'] == products[0]['marketing_status'] for p in products]):\n",
+    "        conflict_status.append(r)\n",
+    "    else:\n",
+    "        valid.append(r)\n",
+    "        \n",
+    "print(f'Total drugs: {len(data)}')\n",
+    "print(f'# with conflicting statuses: {len(conflict_status)}')\n",
+    "print(f'# with no `product` property: {len(no_products)}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "pressed-thread",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "brand_names = []\n",
+    "for v in valid:\n",
+    "    if 'openfda' in v:\n",
+    "        if 'brand_name' in v['openfda']:\n",
+    "            brand_names += v['openfda']['brand_name']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "satellite-petersburg",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "brand_names = set(brand_names)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "id": "particular-balance",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "\n"
+     ]
+    }
+   ],
+   "source": [
+    "print('')\n",
+    "fails = []\n",
+    "for name in brand_names:\n",
+    "    result = q.search_groups(name)\n",
+    "    if result['match_type'] == 0:\n",
+    "        fails.append(name)\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "id": "cubic-apollo",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "# of failed brand name lookups: 2836\n",
+      "# of unique brand names in drugs@fda: 5521\n"
+     ]
+    }
+   ],
+   "source": [
+    "print(f'# of failed brand name lookups: {len(fails)}')\n",
+    "print(f'# of unique brand names in drugs@fda: {len(brand_names)}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "id": "active-jamaica",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "25017"
+      ]
+     },
+     "execution_count": 14,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "len(data)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 29,
+   "id": "seasonal-emperor",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'application_number, openfda, products, sponsor_name, submissions',\n",
+       " 'application_number, openfda, sponsor_name, submissions',\n",
+       " 'application_number, products, sponsor_name',\n",
+       " 'application_number, products, sponsor_name, submissions',\n",
+       " 'application_number, sponsor_name',\n",
+       " 'application_number, sponsor_name, submissions'}"
+      ]
+     },
+     "execution_count": 29,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "key_combos = set()\n",
+    "for d in data:\n",
+    "    key_combos.add(', '.join(sorted(list(d.keys()))))\n",
+    "\n",
+    "key_combos"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 32,
+   "id": "republican-printing",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "'ANDA076165'"
+      ]
+     },
+     "execution_count": 32,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data[0]['application_number']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "victorian-primary",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "therapy-normalization",
+   "language": "python",
+   "name": "therapy-normalization"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/codebuild/deploy_eb_env_dev.py b/codebuild/deploy_eb_env_dev.py
new file mode 100644
index 00000000..37194616
--- /dev/null
+++ b/codebuild/deploy_eb_env_dev.py
@@ -0,0 +1,50 @@
+"""Module for deploying Therapy EB environment."""
+import boto3
+import time
+elasticbeanstalk = boto3.client('elasticbeanstalk')
+servicecatalog = boto3.client('servicecatalog')
+terminate_time = 12
+eb_app_name = "TherapyNormalization"
+eb_env_name = "TherapyNormalization-dev-env"
+sc_product_id = "prod-mmw6ymv2ntzl2"
+print(f'Launching new Service Catalog Product for staging environment: '
+      f'{eb_app_name}')
+sc_product_artifacts = \
+    servicecatalog.list_provisioning_artifacts(ProductId=sc_product_id)
+for artifact in sc_product_artifacts['ProvisioningArtifactDetails']:
+    if artifact['Active']:
+        provisioning_artifact_id = artifact['Id']
+try:
+    eb_provisioned_product = servicecatalog.provision_product(
+        ProductId=sc_product_id,
+        ProvisioningArtifactId=provisioning_artifact_id,
+        ProvisionedProductName=eb_env_name,
+        ProvisioningParameters=[
+            {
+                'Key': 'Env',
+                'Value': eb_app_name
+            },
+            {
+                'Key': 'EnvType',
+                'Value': 'dev'
+            },
+            {
+                'Key': 'TerminateTime',
+                'Value': str(terminate_time)
+            }
+        ])
+    eb_provisioned_product_Id = \
+        eb_provisioned_product['RecordDetail']['ProvisionedProductId']
+    product_status = servicecatalog.describe_provisioned_product(
+        Id=eb_provisioned_product_Id)
+    eb_provisioned_product_status = \
+        product_status['ProvisionedProductDetail']['Status']
+    while eb_provisioned_product_status == "UNDER_CHANGE":
+        time.sleep(10)
+        product_status = servicecatalog.describe_provisioned_product(
+            Id=eb_provisioned_product_Id)
+        eb_provisioned_product_status = \
+            product_status['ProvisionedProductDetail']['Status']
+        print(eb_provisioned_product_status)
+except:  # noqa: E722
+    print("The EB environment is already running...")
diff --git a/codebuild/terminate_eb_env_dev.py b/codebuild/terminate_eb_env_dev.py
new file mode 100644
index 00000000..feb7e61c
--- /dev/null
+++ b/codebuild/terminate_eb_env_dev.py
@@ -0,0 +1,29 @@
+"""Module for terminating Therapy EB environment."""
+import boto3
+import json
+import time
+client = boto3.client('lambda')
+servicecatalog = boto3.client('servicecatalog')
+eb_env_name = "TherapyNormalization-dev-env"
+data = {"sc_provisioned_name": eb_env_name}
+client.invoke(FunctionName='igm-inf-terminate-provisioned-product',
+              Payload=json.dumps(data))
+time.sleep(10)
+provisioned_product = \
+    servicecatalog.describe_provisioned_product(Name=eb_env_name)
+eb_provisioned_product_Id = \
+    provisioned_product['ProvisionedProductDetail']['Id']
+product_status = servicecatalog.describe_provisioned_product(
+    Id=eb_provisioned_product_Id)
+eb_provisioned_product_status = \
+    product_status['ProvisionedProductDetail']['Status']
+while eb_provisioned_product_status == "UNDER_CHANGE":
+    time.sleep(10)
+    try:
+        product_status = servicecatalog.describe_provisioned_product(
+            Id=eb_provisioned_product_Id)
+        eb_provisioned_product_status =\
+            product_status['ProvisionedProductDetail']['Status']
+    except:  # noqa: E722
+        eb_provisioned_product_status = "PRODUCT NOT FOUND"
+    print(eb_provisioned_product_status)
diff --git a/cron.yaml b/cron.yaml
deleted file mode 100644
index 675190d5..00000000
--- a/cron.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-version: 1
-cron: 
-  - name: "task1"
-    url: "/scheduled"
-    schedule: "* * * * *"
diff --git a/dynamodb_revisions/README.md b/dynamodb_revisions/README.md
deleted file mode 100644
index 09011e69..00000000
--- a/dynamodb_revisions/README.md
+++ /dev/null
@@ -1,2 +0,0 @@
-# DynamoDB Revisions
-Files in this directory are used to make updates to the `therapy_concepts` and/or `therapy_metadata` tables in DynamoDB.
\ No newline at end of file
diff --git a/dynamodb_revisions/add_item_type.py b/dynamodb_revisions/add_item_type.py
deleted file mode 100644
index 1d0e91a6..00000000
--- a/dynamodb_revisions/add_item_type.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Add item_type attribute to all items."""
-import sys
-from pathlib import Path
-from timeit import default_timer as timer
-import click
-from botocore.exceptions import ClientError
-import logging
-
-PROJECT_ROOT = Path(__file__).resolve().parents[1]
-sys.path.append(f"{PROJECT_ROOT}")
-
-from therapy.database import Database  # noqa: E402
-
-
-logger = logging.getLogger('therapy')
-logger.setLevel(logging.DEBUG)
-
-db = Database()
-
-
-def add_item_type(label_and_type: str, concept_id: str, item_type: str):
-    """Add item_type to individual db item."""
-    key = {
-        'label_and_type': label_and_type,
-        'concept_id': concept_id
-    }
-    update_expression = "set item_type=:r"
-    update_values = {':r': item_type}
-    try:
-        db.therapies.update_item(Key=key,
-                                 UpdateExpression=update_expression,
-                                 ExpressionAttributeValues=update_values)
-    except ClientError as e:
-        logger.error(f"boto3 client error in `database.update_record()`: "
-                     f"{e.response['Error']['Message']}")
-
-
-VALID_TYPES = {'identity', 'label', 'trade_name', 'rx_brand', 'alias',
-               'other_id', 'xref', 'merger'}
-
-
-def add_item_types():
-    """Add item_type attribute to all items."""
-    last_evaluated_key = None
-    while True:
-        if last_evaluated_key:
-            response = db.therapies.scan(ExclusiveStartKey=last_evaluated_key)
-        else:
-            response = db.therapies.scan()
-
-        records = response['Items']
-        for record in records:
-            label_and_type = record['label_and_type']
-            item_type = label_and_type.split('##')[-1]
-            if item_type in VALID_TYPES:
-                add_item_type(label_and_type, record['concept_id'], item_type)
-            else:
-                logger.error(f"Couldn't parse item type for record: {record}")
-
-        last_evaluated_key = response.get('LastEvaluatedKey')
-        if not last_evaluated_key:
-            break
-
-
-if __name__ == '__main__':
-    click.echo("Adding item_types...")
-    start = timer()
-    add_item_types()
-    end = timer()
-    click.echo(f"finished adding item_types in {end - start:.5f}s.")
diff --git a/dynamodb_revisions/add_merge.py b/dynamodb_revisions/add_merge.py
deleted file mode 100644
index 6406e49e..00000000
--- a/dynamodb_revisions/add_merge.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""Add merged records and references to database."""
-from timeit import default_timer as timer
-from pathlib import Path
-import sys
-import logging
-
-PROJECT_ROOT = Path(__file__).resolve().parents[1]
-sys.path.append(f"{PROJECT_ROOT}")
-
-from therapy.database import Database  # noqa: E402
-from therapy.etl.merge import Merge  # noqa: E402
-from therapy import ACCEPTED_SOURCES  # noqa: #402
-
-logger = logging.getLogger('therapy')
-logger.setLevel(logging.DEBUG)
-
-
-def main():
-    """Execute scan on DB to gather concept IDs and add merged concepts."""
-    start = timer()
-    db = Database()
-
-    logger.info("Compiling concept IDs...")
-    concept_ids = []
-    done = False
-    start_key = None
-    args = dict()
-    while not done:
-        if start_key:
-            args['ExclusiveStartKey'] = start_key
-        response = db.therapies.scan(**args)
-        items = response['Items']
-        for item in items:
-            if item['label_and_type'].endswith('##identity'):
-                if item['src_name'].lower() in ACCEPTED_SOURCES:
-                    concept_ids.append(item['concept_id'])
-
-        start_key = response.get('LastEvaluatedKey', None)
-        done = start_key is None
-
-    logger.info("Concept ID scan successful.")
-    logger.info(f"{len(concept_ids)} concept IDs gathered.")
-
-    merge = Merge(db)
-
-    merge.create_merged_concepts(concept_ids)
-    end = timer()
-    logger.info(f"Merge generation successful, time: {end - start}")
-
-
-if __name__ == '__main__':
-    main()
diff --git a/dynamodb_revisions/add_xref_lookup.py b/dynamodb_revisions/add_xref_lookup.py
deleted file mode 100644
index 2a97aa6d..00000000
--- a/dynamodb_revisions/add_xref_lookup.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Add xref reference to current concepts."""
-import sys
-from pathlib import Path
-from timeit import default_timer as timer
-import click
-
-PROJECT_ROOT = Path(__file__).resolve().parents[1]
-sys.path.append(f"{PROJECT_ROOT}")
-
-from therapy.database import Database  # noqa: E402
-
-
-def add_xref_refs():
-    """Add reference for xref attribute."""
-    db = Database()
-    batch = db.therapies.batch_writer()
-
-    last_evaluated_key = None
-    while True:
-        if last_evaluated_key:
-            response = db.therapies.scan(ExclusiveStartKey=last_evaluated_key)
-        else:
-            response = db.therapies.scan()
-        last_evaluated_key = response.get('LastEvaluatedKey')
-
-        records = response['Items']
-        for record in records:
-            if record['label_and_type'].endswith('##identity'):
-                for xref in record.get('xrefs', []):
-                    batch.put_item(Item={
-                        'label_and_type': f"{xref.lower()}##xref",
-                        'concept_id': record['concept_id'].lower(),
-                        'src_name': record['src_name']
-                    })
-
-        if not last_evaluated_key:
-            break
-
-
-if __name__ == '__main__':
-    click.echo("Adding xref references...")
-    start = timer()
-    add_xref_refs()
-    end = timer()
-    click.echo(f"finished adding xref references in {end - start:.5f}s.")
diff --git a/dynamodb_revisions/delete_merged_records.py b/dynamodb_revisions/delete_merged_records.py
deleted file mode 100644
index 1dcc6964..00000000
--- a/dynamodb_revisions/delete_merged_records.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Delete merged records from therapy_concepts DynamoDB Table."""
-import sys
-from pathlib import Path
-import click
-from timeit import default_timer as timer
-
-PROJECT_ROOT = Path(__file__).resolve().parents[1]
-sys.path.append(f"{PROJECT_ROOT}")
-
-from therapy.database import Database  # noqa: E402
-
-
-def delete_merged_records():
-    """Scan the database to delete merged records."""
-    db = Database()
-    done = False
-    start_key = None
-    args = dict()
-    while not done:
-        if start_key:
-            args['ExclusiveStartKey'] = start_key
-        response = db.therapies.scan(**args)
-        records = response['Items']
-
-        with db.therapies.batch_writer(
-                overwrite_by_pkeys=['label_and_type', 'concept_id']
-        ) as batch:
-            for record in records:
-                if record['label_and_type'].endswith('##merger'):
-                    batch.delete_item(
-                        Key={
-                            'label_and_type': record['label_and_type'],
-                            'concept_id': record['concept_id']
-                        }
-                    )
-
-        start_key = response.get('LastEvaluatedKey', None)
-        done = start_key is None
-
-
-if __name__ == '__main__':
-    click.echo('Deleting merged records...')
-    start = timer()
-    delete_merged_records()
-    end = timer()
-    click.echo(f"Deleted merged records in {end-start} seconds.")
diff --git a/dynamodb_revisions/other_id_ref.py b/dynamodb_revisions/other_id_ref.py
deleted file mode 100644
index c798137e..00000000
--- a/dynamodb_revisions/other_id_ref.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""Other_id ref, update normalized data for production DynamoDB instance."""
-from therapy.database import Database
-from timeit import default_timer as timer
-from boto3.dynamodb.conditions import Attr
-from therapy.schemas import ACCEPTED_SOURCES
-
-db = Database()
-db.batch = db.therapies.batch_writer(overwrite_by_pkeys=['label_and_type',
-                                                         'concept_id'])
-
-
-def perform_updates():
-    """Update records:
-    * Change merged record IDs from serialized concept IDs to main IDs. Also
-      update references from identity records.
-    * Add other_id refs for all records in `other_identifiers` field in
-      identity record.
-    """
-    # update merge_refs and add other_id refs for normalized sources
-    last_evaluated_key = None
-    while True:
-        if last_evaluated_key:
-            response = db.therapies.scan(ExclusiveStartKey=last_evaluated_key)
-        else:
-            response = db.therapies.scan()
-        records = response.get('Items', [])
-
-        for record in records:
-            if record['label_and_type'].endswith('identity'):
-                concept_id = record['concept_id']
-                if record['src_name'].lower() in ACCEPTED_SOURCES:
-                    old_ref = record['merge_ref']
-                    if '|' in old_ref:
-                        new_ref = old_ref.split('|', 1)[0]
-                        db.update_record(concept_id, 'merge_ref', new_ref)
-
-                for other_id in record.get('other_identifiers', []):
-                    db.add_ref_record(other_id, concept_id, 'other_id')
-
-        last_evaluated_key = response.get('LastEvaluatedKey')
-        if not last_evaluated_key:
-            break
-
-    # update concept IDs for merged records
-    last_evaluated_key = None
-    merge_filter = Attr('src_name').not_exists()
-    while True:
-        if last_evaluated_key:
-            response = db.therapies.scan(ExclusiveStartKey=last_evaluated_key,
-                                         FilterExpression=merge_filter)
-        else:
-            response = db.therapies.scan(FilterExpression=merge_filter)
-
-        records = response.get('Items', [])
-
-        for record in records:
-            old_label_and_type = record['label_and_type']
-            if old_label_and_type.endswith('merger'):
-                old_concept_id = record['concept_id']
-
-                new_concept_id = old_concept_id.split('|', 1)[0]
-                if new_concept_id != old_concept_id:
-                    db.batch.delete_item(Key={
-                        'label_and_type': old_label_and_type,
-                        'concept_id': old_concept_id,
-                    })
-                    record['concept_id'] = new_concept_id
-                    record['label_and_type'] = \
-                        f"{new_concept_id.lower()}##merger"
-                    db.add_record(record, 'merger')
-
-        last_evaluated_key = response.get('LastEvaluatedKey')
-        if not last_evaluated_key:
-            break
-    db.flush_batch()
-
-
-if __name__ == '__main__':
-    print("Performing updates...")
-    start = timer()
-    perform_updates()
-    end = timer()
-    print(f"Updated records in {end - start:.5f} seconds")
diff --git a/dynamodb_revisions/update_meta.py b/dynamodb_revisions/update_meta.py
deleted file mode 100644
index 8125870c..00000000
--- a/dynamodb_revisions/update_meta.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Update source metadata in prod"""
-import sys
-from pathlib import Path
-
-PROJECT_ROOT = Path(__file__).resolve().parents[1]
-sys.path.append(f"{PROJECT_ROOT}")
-
-from therapy.database import Database  # noqa: E402
-from therapy.schemas import SourceName  # noqa: E402
-
-
-def main():
-    """Update DB with new source meta info."""
-    db = Database()
-    updates = {
-        SourceName.CHEMBL.value: {
-            "rdp_url": "http://reusabledata.org/chembl.html",
-            "data_license_attributes": {
-                "non_commercial": False,
-                "share_alike": True,
-                "attribution": True
-            }
-        },
-        SourceName.DRUGBANK.value: {
-            "rdp_url": "http://reusabledata.org/drugbank.html",
-            "data_license_attributes": {
-                "non_commercial": True,
-                "share_alike": False,
-                "attribution": True
-            }
-        },
-        SourceName.NCIT.value: {
-            "rdp_url": "http://reusabledata.org/ncit.html",
-            "data_license_attributes": {
-                "non_commercial": False,
-                "share_alike": False,
-                "attribution": True
-            }
-        },
-        SourceName.WIKIDATA.value: {
-            "rdp_url": None,
-            "data_license_attributes": {
-                "non_commercial": False,
-                "share_alike": False,
-                "attribution": False
-            }
-        }
-    }
-
-    for src_name in updates.keys():
-        attrs = {k: {'Value': v} for k, v in updates[src_name].items()}
-        db.metadata.update_item(
-            Key={'src_name': src_name},
-            AttributeUpdates=attrs
-        )
-
-
-if __name__ == '__main__':
-    main()
diff --git a/dynamodb_revisions/update_xrefs_other_ids.py b/dynamodb_revisions/update_xrefs_other_ids.py
deleted file mode 100644
index 32e4652c..00000000
--- a/dynamodb_revisions/update_xrefs_other_ids.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""Update xrefs and other_identifiers attribute."""
-import sys
-from pathlib import Path
-import click
-from timeit import default_timer as timer
-
-PROJECT_ROOT = Path(__file__).resolve().parents[1]
-sys.path.append(f"{PROJECT_ROOT}")
-
-from therapy.database import Database  # noqa: E402
-from therapy import PREFIX_LOOKUP  # noqa: E402
-
-
-def update_xrefs_other_ids(db):
-    """Update xrefs and other_identifiers in therapy identity concepts.
-
-    :param Database db: DynamoDB object
-    """
-    last_evaluated_key = None
-
-    while True:
-        if last_evaluated_key:
-            response = db.dynamodb_client.scan(
-                TableName=db.therapies.name,
-                ExclusiveStartKey=last_evaluated_key,
-                FilterExpression='src_name <> :src_name',
-                ExpressionAttributeValues={
-                    ':src_name': {'S': 'ChEMBL'}
-                }
-            )
-        else:
-            response = db.dynamodb_client.scan(
-                TableName=db.therapies.name,
-                FilterExpression='src_name <> :src_name',
-                ExpressionAttributeValues={
-                    ':src_name': {'S': 'ChEMBL'}
-                }
-            )
-        last_evaluated_key = response.get('LastEvaluatedKey')
-
-        records = response['Items']
-        for record in records:
-            record_identity = record['label_and_type']['S']
-            record_concept_id = record['concept_id']['S']
-            if '##identity' in record_identity:
-                other_ids = []
-                xrefs = []
-                for attr in ['other_identifiers', 'xrefs']:
-                    _add_xrefs_other_ids(record, attr, other_ids, xrefs)
-                update_item(db, record_identity, record_concept_id,
-                            other_ids, xrefs)
-
-        if not last_evaluated_key:
-            break
-
-
-def _add_xrefs_other_ids(record, attr, other_ids, xrefs):
-    """Add xrefs or other_identifiers to the corresponding list.
-
-    :param dict record: The therapy identity concept
-    :param str attr: `other_identifiers` or `xrefs`
-    :param list other_ids: The other identifiers for the therapy concept
-    :param list xrefs: The xrefs for the therapy concept
-    """
-    if attr in record:
-        if 'NULL' not in record[attr]:
-            prev_ids = record[attr]['L']
-            for prev_id in prev_ids:
-                other_id_xref = prev_id['S']
-                if other_id_xref.split(':')[0] in PREFIX_LOOKUP:
-                    other_ids.append(other_id_xref)
-                else:
-                    xrefs.append(other_id_xref)
-
-
-def update_item(db, record_identity, record_concept_id, other_ids, xrefs):
-    """Update therapy identity concept with associated other_identifiers
-    and xrefs attributes.
-
-    :param Database db: DynamoDB object
-    :param str record_identity: The therapy identity concept label_and_type
-                                value
-    :param str record_concept_id: The therapy identity concept id
-    :param list other_ids: The other identifiers for the therapy concept
-    :param list xrefs: The xrefs for the therapy concept
-    """
-    db.therapies.update_item(
-        Key={
-            'label_and_type': record_identity,
-            'concept_id': record_concept_id
-        },
-        UpdateExpression="set other_identifiers=:o, xrefs=:x",
-        ExpressionAttributeValues={
-            ':o': other_ids,
-            ':x': xrefs,
-        },
-        ReturnValues="UPDATED_NEW"
-    )
-
-    # Delete empty lists
-
-    if not xrefs:
-        db.therapies.update_item(
-            Key={
-                'label_and_type': record_identity,
-                'concept_id': record_concept_id
-            },
-            UpdateExpression="remove xrefs",
-            ReturnValues="UPDATED_NEW"
-        )
-
-    if not other_ids:
-        db.therapies.update_item(
-            Key={
-                'label_and_type': record_identity,
-                'concept_id': record_concept_id
-            },
-            UpdateExpression="remove other_identifiers",
-            ReturnValues="UPDATED_NEW"
-        )
-
-
-if __name__ == '__main__':
-    click.echo("Updating xrefs and other_identifiers...")
-    start = timer()
-    update_xrefs_other_ids(Database())
-    end = timer()
-    click.echo(f"Updated xrefs and other_identifiers in {end-start} seconds.")
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..864b334a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta:__legacy__"
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 2fe2ed26..074b2700 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -10,8 +10,8 @@
 
 -i https://pypi.org/simple
 -e .
-anyio==3.3.0; python_full_version >= '3.6.2'
-appnope==0.1.2; sys_platform == 'darwin' and platform_system == 'Darwin'
+anyio==3.4.0; python_full_version >= '3.6.2'
+appnope==0.1.2; platform_system == 'Darwin'
 argon2-cffi==21.1.0; python_version >= '3.5'
 asgiref==3.4.1; python_version >= '3.6'
 attrs==21.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
@@ -19,137 +19,150 @@ babel==2.9.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2,
 backcall==0.2.0
 backoff==1.11.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
 backports-datetime-fromisoformat==1.0.0
-backports.entry-points-selectable==1.1.0; python_version >= '2.7'
-beautifulsoup4==4.9.3
+backports.entry-points-selectable==1.1.1; python_version >= '2.7'
+beautifulsoup4==4.10.0; python_version >= '3.1'
+bioregistry==0.4.18; python_version >= '3.6'
+bioversions==0.4.3
 bleach==4.1.0; python_version >= '3.6'
-boto3==1.18.36
-botocore==1.21.36; python_version >= '3.6'
+boto3==1.20.14
+botocore==1.23.14; python_version >= '3.6'
 bs4==0.0.1
-certifi==2021.5.30
-cffi==1.14.6
+cachier==1.5.4
+certifi==2021.10.8
+cffi==1.15.0
 cfgv==3.3.1; python_full_version >= '3.6.1'
-charset-normalizer==2.0.4; python_version >= '3.0'
+charset-normalizer==2.0.8; python_version >= '3'
+chembl-downloader==0.1.2
 civicpy==1.1.3
-click==8.0.1
-colorama==0.4.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
-coverage==5.5
-coveralls==3.2.0
+click-default-group==1.2.2
+click==8.0.3
+coverage[toml]==6.1.2; python_version >= '3.6'
 cython==0.29.24
-debugpy==1.4.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
-decorator==5.0.9; python_version >= '3.5'
+dataclasses-json==0.5.6; python_version >= '3.6'
+debugpy==1.5.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+decorator==5.1.0; python_version >= '3.5'
 defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
 deprecation==2.1.0
-disease-normalizer==0.2.10
-distlib==0.3.2
-docopt==0.6.2
-docutils==0.17.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+disease-normalizer==0.2.11
+distlib==0.3.3
 entrypoints==0.3; python_version >= '2.7'
-fastapi==0.68.1
-filelock==3.0.12
+fastapi==0.70.0
+filelock==3.4.0; python_version >= '3.6'
+flake8-annotations==2.7.0
 flake8-docstrings==1.6.0
-flake8==3.9.2
-ga4gh.vrsatile.pydantic==0.0.3
+flake8-import-order==0.18.1
+flake8-quotes==3.3.1
+flake8==4.0.1
+ga4gh.vrsatile.pydantic==0.0.5
 h11==0.12.0; python_version >= '3.6'
 httptools==0.3.0
-identify==2.2.13; python_full_version >= '3.6.1'
-idna==3.2; python_version >= '3.0'
-importlib-metadata==4.8.1; python_version >= '3.6'
+identify==2.4.0; python_full_version >= '3.6.1'
+idna==3.3; python_version >= '3.5'
 iniconfig==1.1.1
-ipykernel==6.3.1
+ipykernel==6.5.1
 ipython-genutils==0.2.0
-ipython==7.27.0; python_version >= '3.7'
+ipython==7.29.0; python_version >= '3.7'
 isodate==0.6.0
-jedi==0.18.0; python_version >= '3.6'
-jinja2==3.0.1; python_version >= '3.6'
+jedi==0.18.1; python_version >= '3.6'
+jinja2==3.0.3; python_version >= '3.6'
 jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
 json5==0.9.6
-jsonschema==3.2.0
-jupyter-client==7.0.2; python_full_version >= '3.6.1'
-jupyter-core==4.7.1; python_version >= '3.6'
-jupyter-server==1.10.2; python_version >= '3.6'
+jsonschema==4.2.1; python_version >= '3.7'
+jupyter-client==7.1.0; python_full_version >= '3.6.1'
+jupyter-core==4.9.1; python_version >= '3.6'
+jupyter-server==1.12.1; python_version >= '3.6'
 jupyterlab-pygments==0.1.2
-jupyterlab-server==2.8.1; python_version >= '3.6'
-jupyterlab==3.1.10
-keyring==23.1.0; python_version >= '3.6'
-lxml==4.6.3
+jupyterlab-server==2.8.2; python_version >= '3.6'
+jupyterlab==3.2.4
+lxml==4.6.4
 markupsafe==2.0.1; python_version >= '3.6'
+marshmallow-enum==1.5.1
+marshmallow==3.14.1; python_version >= '3.6'
 matplotlib-inline==0.1.3; python_version >= '3.5'
 mccabe==0.6.1
 mistune==0.8.4
+more-click==0.0.6; python_version >= '3.6'
 mwoauth==0.3.7
-nbclassic==0.3.1; python_version >= '3.6'
-nbclient==0.5.4; python_full_version >= '3.6.1'
-nbconvert==6.1.0; python_version >= '3.7'
+mypy-extensions==0.4.3
+mypy==0.910
+nbclassic==0.3.4; python_version >= '3.6'
+nbclient==0.5.9; python_full_version >= '3.6.1'
+nbconvert==6.3.0; python_version >= '3.7'
 nbformat==5.1.3; python_version >= '3.5'
 nest-asyncio==1.5.1; python_version >= '3.5'
-networkx==2.6.2; python_version >= '3.7'
+networkx==2.6.3; python_version >= '3.7'
 nodeenv==1.6.0
-notebook==6.4.3; python_version >= '3.6'
-numpy==1.21.2; python_version < '3.11' and python_version >= '3.7'
+notebook==6.4.6; python_version >= '3.6'
+numpy==1.21.4; python_version < '3.10' and platform_machine != 'aarch64' and platform_machine != 'arm64'
 oauthlib==3.1.1; python_version >= '3.6'
 obonet==0.3.0; python_version >= '3.5'
-owlready2==0.34
-packaging==21.0; python_version >= '3.6'
-pandas==1.3.2; python_full_version >= '3.7.1'
-pandocfilters==1.4.3
+owlready2==0.35
+packaging==21.3; python_version >= '3.6'
+pandas==1.3.4; python_full_version >= '3.7.1'
+pandocfilters==1.5.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
 parso==0.8.2; python_version >= '3.6'
+pathtools==0.1.2
 pexpect==4.8.0; sys_platform != 'win32'
 pickleshare==0.7.5
-pkginfo==1.7.1
-platformdirs==2.3.0; python_version >= '3.6'
+platformdirs==2.4.0; python_version >= '3.6'
 pluggy==1.0.0; python_version >= '3.6'
+portalocker==2.3.2
 pre-commit==2.15.0
-prometheus-client==0.11.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-prompt-toolkit==3.0.20; python_full_version >= '3.6.2'
+prometheus-client==0.12.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
+prompt-toolkit==3.0.23; python_full_version >= '3.6.2'
 ptyprocess==0.7.0
-py==1.10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-pycodestyle==2.7.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-pycparser==2.20; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
+py==1.11.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+pycodestyle==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+pycparser==2.21; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
 pydantic==1.8.2
 pydocstyle==6.1.1; python_version >= '3.6'
-pyflakes==2.3.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
+pyflakes==2.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
 pygments==2.10.0; python_version >= '3.5'
 pyjwt==1.7.1
-pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
+pyparsing==2.4.7
 pyrsistent==0.18.0; python_version >= '3.6'
-pysam==0.16.0.1
-pytest-cov==2.12.1
+pysam==0.18.0
+pystow==0.2.5
+pytest-cov==3.0.0
 pytest==6.2.5
 python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-pytz==2021.1
-pyyaml==5.4.1
-pyzmq==22.2.1; python_version >= '3.6'
-rdflib==6.0.0
-readme-renderer==29.0
+pytz==2021.3
+pyyaml==6.0
+pyzmq==22.3.0; python_version >= '3.6'
+rdflib==6.0.2
+requests-ftp==0.3.1
 requests-oauthlib==1.3.0
-requests-toolbelt==0.9.1
-requests-unixsocket==0.2.0
 requests==2.26.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
-rfc3986==1.5.0
 s3transfer==0.5.0; python_version >= '3.6'
 send2trash==1.8.0
-simplejson==3.17.5; python_version >= '2.5' and python_version not in '3.0, 3.1, 3.2, 3.3'
+setuptools==59.3.0; python_version >= '3.6'
+simplejson==3.17.6; python_version >= '2.5' and python_version not in '3.0, 3.1, 3.2, 3.3'
 six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
 sniffio==1.2.0; python_version >= '3.5'
-snowballstemmer==2.1.0
-soupsieve==2.2.1; python_version >= '3.0'
-starlette==0.14.2; python_version >= '3.6'
+snowballstemmer==2.2.0
+soupsieve==2.3.1; python_version >= '3.6'
+starlette==0.16.0; python_version >= '3.6'
+tabulate==0.8.9
 terminado==0.12.1; python_version >= '3.6'
 testpath==0.5.0; python_version >= '3.5'
 toml==0.10.2; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
+tomli==1.2.2; python_version >= '3.6'
 tornado==6.1; python_version >= '3.5'
-tqdm==4.62.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-traitlets==5.1.0; python_version >= '3.7'
-twine==3.4.2
-typing-extensions==3.10.0.2
-urllib3==1.26.6
+tox==3.24.4
+tqdm==4.62.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
+traitlets==5.1.1; python_version >= '3.7'
+types-pyyaml==6.0.1
+types-requests==2.26.1
+typing-extensions==4.0.0
+typing-inspect==0.7.1
+urllib3==1.26.7
 uvicorn==0.15.0
 uvloop==0.16.0
 vcfpy==0.13.3
-virtualenv==20.7.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+virtualenv==20.10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+watchdog==2.1.6; python_version >= '3.6'
 wcwidth==0.2.5
 webencodings==0.5.1
 websocket-client==1.2.1; python_version >= '3.6'
-websockets==9.1
+websockets==10.1
 wikibaseintegrator==0.11.0
-zipp==3.5.0; python_version >= '3.6'
diff --git a/requirements.txt b/requirements.txt
index 4de120ff..fc7e4f2c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,81 +6,87 @@
 #
 
 -i https://pypi.org/simple
-appnope==0.1.2; sys_platform == 'darwin' and platform_system == 'Darwin'
+anyio==3.4.0; python_full_version >= '3.6.2'
+appnope==0.1.2; platform_system == 'Darwin'
 asgiref==3.4.1; python_version >= '3.6'
 backcall==0.2.0
 backoff==1.11.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
-beautifulsoup4==4.9.3
-bleach==4.1.0; python_version >= '3.6'
-boto3==1.18.36
-botocore==1.21.36; python_version >= '3.6'
+beautifulsoup4==4.10.0; python_version >= '3.1'
+bioregistry==0.4.18; python_version >= '3.6'
+bioversions==0.4.3
+boto3==1.20.14
+botocore==1.23.14; python_version >= '3.6'
 bs4==0.0.1
-certifi==2021.5.30
-charset-normalizer==2.0.4; python_version >= '3.0'
-click==8.0.1
-colorama==0.4.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+cachier==1.5.4
+certifi==2021.10.8
+charset-normalizer==2.0.8; python_version >= '3'
+chembl-downloader==0.1.2
+click-default-group==1.2.2
+click==8.0.3
 cython==0.29.24
-debugpy==1.4.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
-decorator==5.0.9; python_version >= '3.5'
-disease-normalizer==0.2.10
-docutils==0.17.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+dataclasses-json==0.5.6; python_version >= '3.6'
+debugpy==1.5.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+decorator==5.1.0; python_version >= '3.5'
+disease-normalizer==0.2.11
 entrypoints==0.3; python_version >= '2.7'
-fastapi==0.68.1
-ga4gh.vrsatile.pydantic==0.0.3
+fastapi==0.70.0
+ga4gh.vrsatile.pydantic==0.0.5
 h11==0.12.0; python_version >= '3.6'
 httptools==0.3.0
-idna==3.2; python_version >= '3.0'
-importlib-metadata==4.8.1; python_version >= '3.6'
-ipykernel==6.3.1
-ipython-genutils==0.2.0
-ipython==7.27.0; python_version >= '3.7'
+idna==3.3; python_version >= '3.5'
+ipykernel==6.5.1
+ipython==7.29.0; python_version >= '3.7'
 isodate==0.6.0
-jedi==0.18.0; python_version >= '3.6'
+jedi==0.18.1; python_version >= '3.6'
 jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
-jupyter-client==7.0.2; python_full_version >= '3.6.1'
-jupyter-core==4.7.1; python_version >= '3.6'
-keyring==23.1.0; python_version >= '3.6'
-lxml==4.6.3
+jupyter-client==7.1.0; python_full_version >= '3.6.1'
+jupyter-core==4.9.1; python_version >= '3.6'
+lxml==4.6.4
+marshmallow-enum==1.5.1
+marshmallow==3.14.1; python_version >= '3.6'
 matplotlib-inline==0.1.3; python_version >= '3.5'
+more-click==0.0.6; python_version >= '3.6'
 mwoauth==0.3.7
+mypy-extensions==0.4.3
 nest-asyncio==1.5.1; python_version >= '3.5'
 oauthlib==3.1.1; python_version >= '3.6'
-owlready2==0.34
-packaging==21.0; python_version >= '3.6'
+owlready2==0.35
 parso==0.8.2; python_version >= '3.6'
+pathtools==0.1.2
 pexpect==4.8.0; sys_platform != 'win32'
 pickleshare==0.7.5
-pkginfo==1.7.1
-prompt-toolkit==3.0.20; python_full_version >= '3.6.2'
+portalocker==2.3.2
+prompt-toolkit==3.0.23; python_full_version >= '3.6.2'
 ptyprocess==0.7.0
 pydantic==1.8.2
 pygments==2.10.0; python_version >= '3.5'
 pyjwt==1.7.1
-pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
+pyparsing==2.4.7
+pystow==0.2.5
 python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-pyyaml==5.4.1
-pyzmq==22.2.1; python_version >= '3.6'
-rdflib==6.0.0
-readme-renderer==29.0
+pyyaml==6.0
+pyzmq==22.3.0; python_version >= '3.6'
+rdflib==6.0.2
+requests-ftp==0.3.1
 requests-oauthlib==1.3.0
-requests-toolbelt==0.9.1
 requests==2.26.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
-rfc3986==1.5.0
 s3transfer==0.5.0; python_version >= '3.6'
-simplejson==3.17.5; python_version >= '2.5' and python_version not in '3.0, 3.1, 3.2, 3.3'
+setuptools==59.3.0; python_version >= '3.6'
+simplejson==3.17.6; python_version >= '2.5' and python_version not in '3.0, 3.1, 3.2, 3.3'
 six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-soupsieve==2.2.1; python_version >= '3.0'
-starlette==0.14.2; python_version >= '3.6'
+sniffio==1.2.0; python_version >= '3.5'
+soupsieve==2.3.1; python_version >= '3.6'
+starlette==0.16.0; python_version >= '3.6'
+tabulate==0.8.9
 tornado==6.1; python_version >= '3.5'
-tqdm==4.62.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
-traitlets==5.1.0; python_version >= '3.7'
-twine==3.4.2
-typing-extensions==3.10.0.2
-urllib3==1.26.6
+tqdm==4.62.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
+traitlets==5.1.1; python_version >= '3.7'
+typing-extensions==4.0.0
+typing-inspect==0.7.1
+urllib3==1.26.7
 uvicorn==0.15.0
 uvloop==0.16.0
+watchdog==2.1.6; python_version >= '3.6'
 wcwidth==0.2.5
-webencodings==0.5.1
-websockets==9.1
+websockets==10.1
 wikibaseintegrator==0.11.0
-zipp==3.5.0; python_version >= '3.6'
diff --git a/setup.cfg b/setup.cfg
index 0cdba10f..96873d80 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,27 +15,43 @@ zip_safe = False
 include_package_data = True
 
 install_requires =
-    fastapi
+    fastapi >= 0.65.2
+    urllib3 >= 1.26.5
     uvicorn
+    uvloop
+    websockets >= 9.1
     click
-    lxml
+    lxml >= 4.6.3
     boto3
     cython
     owlready2
+    pyparsing == 2.4.7
     rdflib
     wikibaseintegrator == 0.11.0
     bs4
     pyyaml
-    pydantic
-    disease-normalizer
+    pydantic >=1.8.2
+    chembl-downloader
+    pystow
+    bioversions >= 0.4.3
+    disease-normalizer >= 0.2.11
+    ga4gh.vrsatile.pydantic >= 0.0.5
 
 tests_require =
     pytest
     pytest-cov
 
-[options.package_data]
-therapy =
-    etl/rxnorm_download.sh
-
 [tool:pytest]
-addopts = --ignore setup.py --doctest-modules --cov-report term-missing --cov .
+addopts = --ignore setup.py --ignore=codebuild/ --ignore=analysis/ --ignore=dynamodb_revisions/ --doctest-modules --cov-report term-missing --cov .
+
+[mypy]
+plugins = pydantic.mypy
+ignore_missing_imports = True
+
+[coverage:run]
+source =
+   therapy/
+   tests/
+omit =
+   setup.py
+   docs/*
diff --git a/setup.py b/setup.py
index 66118618..03d3083e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,5 @@
 """Defines how metakb is packaged and distributed."""
 from setuptools import setup
 
-exec(open('therapy/version.py').read())
+exec(open("therapy/version.py").read())
 setup(version=__version__)  # noqa: F821
diff --git a/therapy.zip b/therapy.zip
deleted file mode 100644
index 74c80e3b..00000000
Binary files a/therapy.zip and /dev/null differ
diff --git a/therapy/__init__.py b/therapy/__init__.py
index 7bb94e3f..6c878434 100644
--- a/therapy/__init__.py
+++ b/therapy/__init__.py
@@ -1,25 +1,32 @@
 """The VICC library for normalizing therapies."""
-from .version import __version__  # noqa: F401
 from pathlib import Path
 import logging
+import re
 
-PROJECT_ROOT = Path(__file__).resolve().parents[0]
+from .version import __version__
+
+APP_ROOT: Path = Path(__file__).resolve().parents[0]
 logging.basicConfig(
-    filename='therapy.log',
-    format='[%(asctime)s] - %(name)s - %(levelname)s : %(message)s')
-logger = logging.getLogger('therapy')
+    filename="therapy.log",
+    format="[%(asctime)s] - %(name)s - %(levelname)s : %(message)s")
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class DownloadException(Exception):
     """Exception for failures relating to source file downloads."""
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs):  # noqa: ANN204
         """Initialize exception."""
         super().__init__(*args, **kwargs)
 
 
-from therapy.schemas import SourceName, NamespacePrefix, SourceIDAfterNamespace, ProhibitedSources, ItemTypes  # noqa: E402, E501
+from therapy.schemas import SourceName, NamespacePrefix, ItemTypes  # noqa: E402, E501, I100, I202
+
+# map plural to singular form
+# eg {"label": "label", "trade_names": "trade_name"}
+# key is the field name in the record object, value is the item_type value
+# in reference objects
 ITEM_TYPES = {k.lower(): v.value for k, v in ItemTypes.__members__.items()}
 
 # Sources we import directly
@@ -32,26 +39,24 @@ def __init__(self, *args, **kwargs):
                  for k, v in NamespacePrefix.__members__.items()
                  if k in SourceName.__members__.keys()}
 
-# use to generate namespace prefix from source ID value
-# e.g. {'q': 'wikidata'}
-NAMESPACE_LOOKUP = {v.value.lower(): NamespacePrefix[k].value
-                    for k, v in SourceIDAfterNamespace.__members__.items()
-                    if v.value != ''}
-
-# Sources that are not allowed in normalize endpoint due to license
-PROHIBITED_SOURCES = {s.value.lower()
-                      for s in ProhibitedSources.__members__.values()}
-
-# Sources that are allowed in normalize endpoint
-ACCEPTED_SOURCES = SOURCES.keys() - PROHIBITED_SOURCES
+# Namespace LUI patterns. Use for namespace inference.
+NAMESPACE_LUIS = (
+    (re.compile(r"^CHEMBL\d+$", re.IGNORECASE), SourceName.CHEMBL.value),
+    (re.compile(r"^\d+\-\d+\-\d+$", re.IGNORECASE), SourceName.CHEMIDPLUS.value),
+    (re.compile(r"^(Q|P)\d+$", re.IGNORECASE), SourceName.WIKIDATA.value),
+    (re.compile(r"^C\d+$", re.IGNORECASE), SourceName.NCIT.value),
+    (re.compile(r"^DB\d{5}$", re.IGNORECASE), SourceName.DRUGBANK.value),
+    (re.compile(r"^(A?NDA)(\d+)$", re.IGNORECASE), SourceName.DRUGSATFDA.value)
+)
 
 # Sources that we import directly
 XREF_SOURCES = {source for source in SourceName.__members__}
 
 # Sources that are found in data from imported sources
-ASSOC_WITH_SOURCES = {source for source in NamespacePrefix.__members__} - XREF_SOURCES  # noqa: E501
+ASSOC_WITH_SOURCES = {source for source in NamespacePrefix.__members__} - XREF_SOURCES
+
+from therapy.etl import ChEMBL, Wikidata, DrugBank, NCIt, ChemIDplus, RxNorm, HemOnc, GuideToPHARMACOLOGY, DrugsAtFDA  # noqa: E402, E501, I202
 
-from therapy.etl import ChEMBL, Wikidata, DrugBank, NCIt, ChemIDplus, RxNorm, HemOnc  # noqa: F401, E402, E501
 # used to get source class name from string
 SOURCES_CLASS = \
     {s.value.lower(): eval(s.value) for s in SourceName.__members__.values()}
diff --git a/therapy/cli.py b/therapy/cli.py
index 1822f1c7..28917321 100644
--- a/therapy/cli.py
+++ b/therapy/cli.py
@@ -1,20 +1,22 @@
 """This module provides a CLI util to make updates to normalizer database."""
+from os import environ
+import logging
+from timeit import default_timer as timer
+from typing import List, Optional
+
 import click
 from botocore.exceptions import ClientError
-from therapy import ACCEPTED_SOURCES
-from therapy.etl.merge import Merge
-from timeit import default_timer as timer
 from boto3.dynamodb.conditions import Key
-from therapy.schemas import SourceName
-from therapy import SOURCES_CLASS, SOURCES
-from therapy.database import Database
 from disease.database import Database as DiseaseDatabase
 from disease.cli import CLI as DiseaseCLI
 from disease.schemas import SourceName as DiseaseSources
-from os import environ
-import logging
 
-logger = logging.getLogger('therapy')
+from therapy import SOURCES_CLASS, SOURCES
+from therapy.schemas import SourceName
+from therapy.database import Database
+from therapy.etl.merge import Merge
+
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
@@ -24,51 +26,61 @@ class CLI:
     @staticmethod
     @click.command()
     @click.option(
-        '--normalizer',
+        "--normalizer",
         help="The normalizer(s) you wish to update separated by spaces."
     )
     @click.option(
-        '--prod',
+        "--prod",
         is_flag=True,
         help="Working in production environment."
     )
     @click.option(
-        '--db_url',
+        "--db_url",
         help="URL endpoint for the application database."
     )
     @click.option(
-        '--update_all',
+        "--update_all",
         is_flag=True,
-        help='Update all normalizer sources.'
+        help="Update all normalizer sources."
     )
     @click.option(
-        '--update_merged',
+        "--update_merged",
         is_flag=True,
-        help='Update concepts for normalize endpoint from accepted sources.'
+        help="Update concepts for normalize endpoint from accepted sources."
     )
-    def update_normalizer_db(normalizer, prod, db_url, update_all,
-                             update_merged):
-        """Update select normalizer source(s) in the therapy database."""
+    def update_normalizer_db(normalizer: str, prod: bool, db_url: str,
+                             update_all: bool, update_merged: bool) -> None:
+        """Update selected normalizer source(s) in the therapy database.
+        \f  # noqa: D301
+        :param str normalizer: comma-separated string listing source names
+        :param bool prod: if true, utilize production environment settings
+        :param str db_url: DynamoDB endpoint URL (usually only needed locally)
+        :param bool update_all: if true, update all sources
+        :param bool update_merged: if true, update normalized group results
+        """
         endpoint_url = None
         if prod:
-            environ['THERAPY_NORM_PROD'] = "TRUE"
-            environ['DISEASE_NORM_PROD'] = "TRUE"
+            environ["THERAPY_NORM_PROD"] = "TRUE"
+            environ["DISEASE_NORM_PROD"] = "TRUE"
             db: Database = Database()
         else:
             if db_url:
                 endpoint_url = db_url
-            elif 'THERAPY_NORM_DB_URL' in environ.keys():
-                endpoint_url = environ['THERAPY_NORM_DB_URL']
+            elif "THERAPY_NORM_DB_URL" in environ.keys():
+                endpoint_url = environ["THERAPY_NORM_DB_URL"]
             else:
-                endpoint_url = 'http://localhost:8000'
-            db: Database = Database(db_url=endpoint_url)
+                endpoint_url = "http://localhost:8000"
+            db = Database(db_url=endpoint_url)
 
         if update_all:
             normalizers = list(src for src in SOURCES)
             CLI()._check_disease_normalizer(normalizers, endpoint_url)
             CLI()._update_normalizers(normalizers, db, update_merged)
         elif not normalizer:
-            CLI()._help_msg()
+            if update_merged:
+                CLI()._update_merged(db, [])
+            else:
+                CLI()._help_msg()
         else:
             normalizers = str(normalizer).lower().split()
 
@@ -83,60 +95,69 @@ def update_normalizer_db(normalizer, prod, db_url, update_all,
             CLI()._check_disease_normalizer(normalizers, endpoint_url)
             CLI()._update_normalizers(normalizers, db, update_merged)
 
-    def _check_disease_normalizer(self, normalizers, endpoint_url):
-        """When loading HemOnc source, perform rudimentary check of Disease
-            Normalizer tables, and reload them if necessary.
+    def _check_disease_normalizer(self, normalizers: List[str],
+                                  endpoint_url: Optional[str]) -> None:
+        """When loading HemOnc source, perform rudimentary check of Disease Normalizer
+        tables, and reload them if necessary. This reload method should never be used
+        (and is restricted from use) in a production setting.
 
-        :param list normalizers: List of sources to load
-        :param str endpoint_url: Therapy endpoint URL
+        :param List[str] normalizers: List of sources to load
+        :param Optional[str] endpoint_url: Therapy endpoint URL. This should always be
+            a local address.
         """
-        def _load_diseases():
-            msg = "Disease Normalizer not loaded. " \
-                  "Loading Disease Normalizer..."
-            logger.debug(msg)
-            click.echo(msg)
-            try:
-                DiseaseCLI().update_normalizer_db(
-                    ['--update_all', '--update_merged',
-                     '--db_url', endpoint_url]
-                )
-            except Exception as e:
-                logger.error(e)
-                raise Exception(e)
-            except:  # noqa: E722
-                pass
-
-        if 'THERAPY_NORM_PROD' not in environ and 'hemonc' in normalizers:
-            db = DiseaseDatabase(db_url=endpoint_url)
+        if "hemonc" in normalizers and "THERAPY_NORM_PROD" not in environ:
+            db = DiseaseDatabase(db_url=endpoint_url)  # type: ignore
             current_tables = {table.name for table in db.dynamodb.tables.all()}
-            if not all(name in current_tables
-                       for name in ('disease_concepts', 'disease_metadata')):
-                _load_diseases()
-            elif db.diseases.scan()['Count'] == 0 or \
-                    db.metadata.scan()['Count'] < len(DiseaseSources):
-                _load_diseases()
+            if ("disease_concepts" not in current_tables) or \
+                    ("disease_metadata" not in current_tables) or \
+                    (db.diseases.scan()["Count"] == 0) or \
+                    (db.metadata.scan()["Count"] < len(DiseaseSources)):
+                msg = "Disease Normalizer not loaded. Loading now..."
+                logger.debug(msg)
+                click.echo(msg)
+                try:
+                    DiseaseCLI().update_normalizer_db(
+                        ["--update_all", "--update_merged", "--db_url", endpoint_url]
+                    )
+                except Exception as e:
+                    logger.error(e)
+                    raise Exception(e)
+                except:  # noqa: E722
+                    # TODO: what does this do?
+                    pass
+                msg = "Disease Normalizer reloaded successfully."
+                logger.debug(msg)
+                click.echo(msg)
 
     @staticmethod
-    def _help_msg():
+    def _help_msg() -> None:
         """Display help message."""
         ctx = click.get_current_context()
         click.echo(
-            "Must either enter 1 or more sources, or use `--update_all` parameter")  # noqa: E501
+            "Must either enter 1 or more sources, or use `--update_all` parameter")
         click.echo(ctx.get_help())
         ctx.exit()
 
     @staticmethod
-    def _update_normalizers(normalizers, db, update_merged):
-        """Update selected normalizer sources."""
+    def _update_normalizers(normalizers: List[str], db: Database,
+                            update_merged: bool) -> None:
+        """Update selected normalizer sources.
+        :param List[str] normalizers: list of source names to update
+        :param Database db: database instance to use
+        :param bool update_merged: if true, store concept IDs as they're processed and
+            produce normalized records
+        """
         processed_ids = list()
         for n in normalizers:
             msg = f"Deleting {n}..."
             click.echo(f"\n{msg}")
             logger.info(msg)
+
             start_delete = timer()
             CLI()._delete_data(n, db)
             end_delete = timer()
             delete_time = end_delete - start_delete
+
             msg = f"Deleted {n} in {delete_time:.5f} seconds."
             click.echo(f"{msg}\n")
             logger.info(msg)
@@ -144,105 +165,117 @@ def _update_normalizers(normalizers, db, update_merged):
             msg = f"Loading {n}..."
             click.echo(msg)
             logger.info(msg)
+
             start_load = timer()
             source = SOURCES_CLASS[n](database=db)
-            if source.in_normalize:
-                processed_ids += source.perform_etl()
-            else:
-                source.perform_etl()
+            processed_ids += source.perform_etl()
             end_load = timer()
             load_time = end_load - start_load
+
             msg = f"Loaded {n} in {load_time:.5f} seconds."
             click.echo(msg)
             logger.info(msg)
+
             msg = f"Total time for {n}: " \
                   f"{(delete_time + load_time):.5f} seconds."
             click.echo(msg)
             logger.info(msg)
 
         if update_merged:
-            start_merge = timer()
-            if not processed_ids or not ACCEPTED_SOURCES.issubset(normalizers):
-                CLI()._delete_normalized_data(db)
-                processed_ids = db.get_ids_for_merge()
-            merge = Merge(database=db)
-            click.echo("Constructing normalized records...")
-            merge.create_merged_concepts(processed_ids)
-            end_merge = timer()
-            click.echo(f"Merged concept generation completed in"
-                       f" {(end_merge - start_merge):.5f} seconds.")
+            CLI()._update_merged(db, processed_ids)
+
+    def _update_merged(self, db: Database, processed_ids: List[str]) -> None:
+        """Build and upload merged records. Will construct list of IDs if given an empty
+        processed_ids list.
+        :param Database db: DB instance to use
+        :param List[str] processed_ids: List of IDs to create merged groups from
+        """
+        start_merge = timer()
+        if not processed_ids:
+            CLI()._delete_normalized_data(db)
+            processed_ids = db.get_ids_for_merge()
+        merge = Merge(database=db)
+        click.echo("Constructing normalized records...")
+        merge.create_merged_concepts(set(processed_ids))
+        end_merge = timer()
+        click.echo(f"Merged concept generation completed in"
+                   f" {(end_merge - start_merge):.5f} seconds.")
 
     @staticmethod
-    def _delete_normalized_data(database):
+    def _delete_normalized_data(database: Database) -> None:
         click.echo("\nDeleting normalized records...")
         start_delete = timer()
         try:
             while True:
                 with database.therapies.batch_writer(
-                        overwrite_by_pkeys=['label_and_type', 'concept_id']) \
+                        overwrite_by_pkeys=["label_and_type", "concept_id"]) \
                         as batch:
                     response = database.therapies.query(
-                        IndexName='item_type_index',
-                        KeyConditionExpression=Key('item_type').eq('merger'),
+                        IndexName="item_type_index",
+                        KeyConditionExpression=Key("item_type").eq("merger"),
                     )
-                    records = response['Items']
+                    records = response["Items"]
                     if not records:
                         break
                     for record in records:
                         batch.delete_item(Key={
-                            'label_and_type': record['label_and_type'],
-                            'concept_id': record['concept_id']
+                            "label_and_type": record["label_and_type"],
+                            "concept_id": record["concept_id"]
                         })
         except ClientError as e:
-            click.echo(e.response['Error']['Message'])
+            click.echo(e.response["Error"]["Message"])
         end_delete = timer()
         delete_time = end_delete - start_delete
         click.echo(f"Deleted normalized records in {delete_time:.5f} seconds.")
 
     @staticmethod
-    def _delete_data(source, database):
-        # Delete source's metadata
+    def _delete_data(source: str, database: Database) -> None:
+        """Delete all data (records + metadata) from given source in database.
+        :param str source: name of source to delete
+        :param Database database: db instance
+        """
+        source_name = SourceName[f"{source.upper()}"].value
+        # Delete source"s metadata first
         try:
             metadata = database.metadata.query(
                 KeyConditionExpression=Key(
-                    'src_name').eq(SourceName[f"{source.upper()}"].value)
+                    "src_name").eq(source_name)
             )
-            if metadata['Items']:
+            if metadata["Items"]:
                 database.metadata.delete_item(
-                    Key={'src_name': metadata['Items'][0]['src_name']},
+                    Key={"src_name": metadata["Items"][0]["src_name"]},
                     ConditionExpression="src_name = :src",
                     ExpressionAttributeValues={
-                        ':src': SourceName[f"{source.upper()}"].value}
+                        ":src": source_name}
                 )
         except ClientError as e:
-            click.echo(e.response['Error']['Message'])
+            click.echo(e.response["Error"]["Message"])
 
         try:
             while True:
                 response = database.therapies.query(
-                    IndexName='src_index',
-                    KeyConditionExpression=Key('src_name').eq(
-                        SourceName[f"{source.upper()}"].value),
+                    IndexName="src_index",
+                    KeyConditionExpression=Key("src_name").eq(
+                        source_name),
                 )
 
-                records = response['Items']
+                records = response["Items"]
                 if not records:
                     break
 
                 with database.therapies.batch_writer(
-                        overwrite_by_pkeys=['label_and_type', 'concept_id']) \
-                        as batch:
+                        overwrite_by_pkeys=["label_and_type", "concept_id"]) as batch:
 
                     for record in records:
                         batch.delete_item(
                             Key={
-                                'label_and_type': record['label_and_type'],
-                                'concept_id': record['concept_id']
+                                "label_and_type": record["label_and_type"],
+                                "concept_id": record["concept_id"]
                             }
                         )
         except ClientError as e:
-            click.echo(e.response['Error']['Message'])
+            click.echo(e.response["Error"]["Message"])
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     CLI().update_normalizer_db()
diff --git a/therapy/database.py b/therapy/database.py
index e01f2e13..9a4de36a 100644
--- a/therapy/database.py
+++ b/therapy/database.py
@@ -1,36 +1,39 @@
 """This module creates the database."""
-import boto3
-from therapy import PREFIX_LOOKUP, PROHIBITED_SOURCES
-from botocore.exceptions import ClientError
-from boto3.dynamodb.conditions import Key
 from os import environ
 from typing import Optional, Dict, List, Any
 import logging
-import click
 import sys
+import atexit
+
+import click
+import boto3
+from boto3.dynamodb.conditions import Key
+from botocore.exceptions import ClientError
 
-logger = logging.getLogger('therapy')
+from therapy import PREFIX_LOOKUP
+
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class Database:
     """The database class."""
 
-    def __init__(self, db_url: str = '', region_name: str = 'us-east-2'):
-        """Initialize Database class.
+    def __init__(self, db_url: str = "", region_name: str = "us-east-2"):
+        """Initialize Database class instance.
 
         :param str db_url: database endpoint URL to connect to
         :param str region_name: AWS region name to use
         """
-        if 'THERAPY_NORM_PROD' in environ or 'THERAPY_NORM_EB_PROD' in environ:
+        if "THERAPY_NORM_PROD" in environ or "THERAPY_NORM_EB_PROD" in environ:
             boto_params = {
-                'region_name': region_name
+                "region_name": region_name
             }
-            self.endpoint_url = ''
-            if 'THERAPY_NORM_PROD' in environ:
-                environ['DISEASE_NORM_PROD'] = "true"
-            if 'THERAPY_NORM_EB_PROD' in environ:
-                environ['DISEASE_NORM_EB_PROD'] = "true"
+            self.endpoint_url = ""
+            if "THERAPY_NORM_PROD" in environ:
+                environ["DISEASE_NORM_PROD"] = "true"
+            if "THERAPY_NORM_EB_PROD" in environ:
+                environ["DISEASE_NORM_EB_PROD"] = "true"
             else:
                 # EB Instance should not have to confirm.
                 # This is used only for using production via CLI
@@ -43,191 +46,188 @@ def __init__(self, db_url: str = '', region_name: str = 'us-east-2'):
         else:
             if db_url:
                 self.endpoint_url = db_url
-            elif 'THERAPY_NORM_DB_URL' in environ:
-                self.endpoint_url = environ['THERAPY_NORM_DB_URL']
+            elif "THERAPY_NORM_DB_URL" in environ:
+                self.endpoint_url = environ["THERAPY_NORM_DB_URL"]
             else:
-                self.endpoint_url = 'http://localhost:8000'
+                self.endpoint_url = "http://localhost:8000"
             click.echo(f"***Using Therapy Database Endpoint: "
                        f"{self.endpoint_url}***")
-            environ['DISEASE_NORM_DB_URL'] = self.endpoint_url
+            environ["DISEASE_NORM_DB_URL"] = self.endpoint_url
 
             boto_params = {
-                'region_name': region_name,
-                'endpoint_url': self.endpoint_url
+                "region_name": region_name,
+                "endpoint_url": self.endpoint_url
             }
 
-        self.dynamodb = boto3.resource('dynamodb', **boto_params)
-        self.dynamodb_client = boto3.client('dynamodb', **boto_params)
+        self.dynamodb = boto3.resource("dynamodb", **boto_params)
+        self.dynamodb_client = boto3.client("dynamodb", **boto_params)
 
         # Table creation for local database
-        if 'THERAPY_NORM_PROD' not in environ and \
-                'THERAPY_NORM_EB_PROD' not in environ:
-            existing_tables = self.dynamodb_client.list_tables()['TableNames']
+        if "THERAPY_NORM_PROD" not in environ and \
+                "THERAPY_NORM_EB_PROD" not in environ:
+            existing_tables = self.dynamodb_client.list_tables()["TableNames"]
             self.create_therapies_table(existing_tables)
             self.create_meta_data_table(existing_tables)
 
-        self.therapies = self.dynamodb.Table('therapy_concepts')
-        self.metadata = self.dynamodb.Table('therapy_metadata')
+        self.therapies = self.dynamodb.Table("therapy_concepts")
+        self.metadata = self.dynamodb.Table("therapy_metadata")
         self.batch = self.therapies.batch_writer()
-        self.cached_sources = {}
+        self.cached_sources: Dict[str, Any] = {}
+        atexit.register(self.flush_batch)
 
-    def create_therapies_table(self, existing_tables: List):
-        """Create Therapies table if it doesn't already exist.
+    def create_therapies_table(self, existing_tables: List[str]) -> None:
+        """Create Therapies table if it doesn"t already exist.
 
-        :param List existing_tables: list of existing table names
+        :param List[str] existing_tables: list of existing table names
         """
-        table_name = 'therapy_concepts'
+        table_name = "therapy_concepts"
         if table_name not in existing_tables:
             self.dynamodb.create_table(
                 TableName=table_name,
                 KeySchema=[
                     {
-                        'AttributeName': 'label_and_type',
-                        'KeyType': 'HASH'  # Partition key
+                        "AttributeName": "label_and_type",
+                        "KeyType": "HASH"  # Partition key
                     },
                     {
-                        'AttributeName': 'concept_id',
-                        'KeyType': 'RANGE'  # Sort key
+                        "AttributeName": "concept_id",
+                        "KeyType": "RANGE"  # Sort key
                     }
                 ],
                 AttributeDefinitions=[
                     {
-                        'AttributeName': 'label_and_type',
-                        'AttributeType': 'S'
+                        "AttributeName": "label_and_type",
+                        "AttributeType": "S"
                     },
                     {
-                        'AttributeName': 'concept_id',
-                        'AttributeType': 'S'
+                        "AttributeName": "concept_id",
+                        "AttributeType": "S"
                     },
                     {
-                        'AttributeName': 'src_name',
-                        'AttributeType': 'S'
+                        "AttributeName": "src_name",
+                        "AttributeType": "S"
                     },
                     {
-                        'AttributeName': 'item_type',
-                        'AttributeType': 'S'
+                        "AttributeName": "item_type",
+                        "AttributeType": "S"
                     }
                 ],
                 GlobalSecondaryIndexes=[
                     {
-                        'IndexName': 'src_index',
-                        'KeySchema': [
+                        "IndexName": "src_index",
+                        "KeySchema": [
                             {
-                                'AttributeName': 'src_name',
-                                'KeyType': 'HASH'
+                                "AttributeName": "src_name",
+                                "KeyType": "HASH"
                             }
                         ],
-                        'Projection': {
-                            'ProjectionType': 'KEYS_ONLY'
+                        "Projection": {
+                            "ProjectionType": "KEYS_ONLY"
                         },
-                        'ProvisionedThroughput': {
-                            'ReadCapacityUnits': 10,
-                            'WriteCapacityUnits': 10
+                        "ProvisionedThroughput": {
+                            "ReadCapacityUnits": 10,
+                            "WriteCapacityUnits": 10
                         }
                     },
                     {
-                        'IndexName': 'item_type_index',
-                        'KeySchema': [
+                        "IndexName": "item_type_index",
+                        "KeySchema": [
                             {
-                                'AttributeName': 'item_type',
-                                'KeyType': 'HASH'
+                                "AttributeName": "item_type",
+                                "KeyType": "HASH"
                             }
                         ],
-                        'Projection': {
-                            'ProjectionType': 'KEYS_ONLY'
+                        "Projection": {
+                            "ProjectionType": "KEYS_ONLY"
                         },
-                        'ProvisionedThroughput': {
-                            'ReadCapacityUnits': 10,
-                            'WriteCapacityUnits': 10
+                        "ProvisionedThroughput": {
+                            "ReadCapacityUnits": 10,
+                            "WriteCapacityUnits": 10
                         }
                     }
                 ],
                 ProvisionedThroughput={
-                    'ReadCapacityUnits': 10,
-                    'WriteCapacityUnits': 10
+                    "ReadCapacityUnits": 10,
+                    "WriteCapacityUnits": 10
                 }
             )
 
-    def create_meta_data_table(self, existing_tables: List):
+    def create_meta_data_table(self, existing_tables: List[str]) -> None:
         """Create MetaData table if not exists.
 
         :param List existing_tables: list of existing table names
         """
-        table_name = 'therapy_metadata'
+        table_name = "therapy_metadata"
         if table_name not in existing_tables:
             self.dynamodb.create_table(
                 TableName=table_name,
                 KeySchema=[
                     {
-                        'AttributeName': 'src_name',
-                        'KeyType': 'HASH'  # Partition key
+                        "AttributeName": "src_name",
+                        "KeyType": "HASH"  # Partition key
                     }
                 ],
                 AttributeDefinitions=[
                     {
-                        'AttributeName': 'src_name',
-                        'AttributeType': 'S'
+                        "AttributeName": "src_name",
+                        "AttributeType": "S"
                     },
                 ],
                 ProvisionedThroughput={
-                    'ReadCapacityUnits': 10,
-                    'WriteCapacityUnits': 10
+                    "ReadCapacityUnits": 10,
+                    "WriteCapacityUnits": 10
                 }
             )
 
-    def get_record_by_id(self, concept_id: str,
-                         case_sensitive: bool = True,
+    def get_record_by_id(self, concept_id: str, case_sensitive: bool = True,
                          merge: bool = False) -> Optional[Dict]:
         """Fetch record corresponding to provided concept ID
 
         :param str concept_id: concept ID for therapy record
-        :param bool case_sensitive: if true, performs exact lookup, which is
-            more efficient. Otherwise, performs filter operation, which
-            doesn't require correct casing.
+        :param bool case_sensitive: if true, performs exact lookup, which is more
+            efficient. Otherwise, performs filter operation, which doesn"t require
+            correct casing.
         :param bool merge: if true, look for merged concept
         :return: complete therapy record, if match is found; None otherwise
         """
         try:
             if merge:
-                pk = f'{concept_id.lower()}##merger'
+                pk = f"{concept_id.lower()}##merger"
             else:
-                pk = f'{concept_id.lower()}##identity'
+                pk = f"{concept_id.lower()}##identity"
             if case_sensitive:
                 match = self.therapies.get_item(Key={
-                    'label_and_type': pk,
-                    'concept_id': concept_id
+                    "label_and_type": pk,
+                    "concept_id": concept_id
                 })
-                return match['Item']
+                return match["Item"]
             else:
-                exp = Key('label_and_type').eq(pk)
+                exp = Key("label_and_type").eq(pk)
                 response = self.therapies.query(KeyConditionExpression=exp)
-                return response['Items'][0]
+                return response["Items"][0]
         except ClientError as e:
             logger.error(f"boto3 client error on get_records_by_id for "
                          f"search term {concept_id}: "
                          f"{e.response['Error']['Message']}")
             return None
-        except KeyError:  # record doesn't exist
-            return None
-        except IndexError:  # record doesn't exist
+        except (KeyError, IndexError):  # record doesn"t exist
             return None
 
     def get_records_by_type(self, query: str,
                             match_type: str) -> List[Dict]:
         """Retrieve records for given query and match type.
 
-        :param query: string to match against
-        :param str match_type: type of match to look for. Should be one
-            of {"alias", "trade_name", "label", "xref", "associated_with",
-            "rx_brand"} -- use Database.get_record_by_id() for concept ID
-            lookup
+        :param query: string to match against (should already be lower-case)
+        :param str match_type: type of match to look for. Should be one of {"alias",
+            "trade_name", "label", "xref", "associated_with", "rx_brand"} -- use
+            Database.get_record_by_id() for concept ID lookup
         :return: list of matching records. Empty if lookup fails.
         """
-        pk = f'{query}##{match_type.lower()}'
-        filter_exp = Key('label_and_type').eq(pk)
+        pk = f"{query}##{match_type.lower()}"
+        filter_exp = Key("label_and_type").eq(pk)
         try:
             matches = self.therapies.query(KeyConditionExpression=filter_exp)
-            return matches.get('Items', None)
+            return matches.get("Items", None)
         except ClientError as e:
             logger.error(f"boto3 client error on get_records_by_type for "
                          f"search term {query}: "
@@ -235,15 +235,14 @@ def get_records_by_type(self, query: str,
             return []
 
     def get_ids_for_merge(self) -> List[str]:
-        """Retrieve concept IDs for use in generating normalized records. Only
-        pulls concept IDs for sources in `therapy.ACCEPTED_SOURCES`.
+        """Retrieve concept IDs for use in generating normalized records.
+
         :return: List of concept IDs as strings.
         """
         last_evaluated_key = None
         concept_ids = []
-        prohibited_lower = {s.lower() for s in PROHIBITED_SOURCES}
         params = {
-            'ProjectionExpression': 'concept_id',
+            "ProjectionExpression": "concept_id,item_type",
         }
         while True:
             if last_evaluated_key:
@@ -252,65 +251,63 @@ def get_ids_for_merge(self) -> List[str]:
                 )
             else:
                 response = self.therapies.scan(**params)
-            records = response['Items']
+            records = response["Items"]
             for record in records:
-                concept_id = record['concept_id']
-                if not any({s for s in prohibited_lower
-                            if concept_id.startswith(s)}):
+                if record["item_type"] == "identity":
+                    concept_id = record["concept_id"]
                     concept_ids.append(concept_id)
-            last_evaluated_key = response.get('LastEvaluatedKey')
+            last_evaluated_key = response.get("LastEvaluatedKey")
             if not last_evaluated_key:
                 break
-        return set(concept_ids)
+        return concept_ids
 
-    def add_record(self, record: Dict, record_type: str = "identity"):
+    def add_record(self, record: Dict, record_type: str = "identity") -> None:
         """Add new record to database.
 
         :param Dict record: record to upload
-        :param str record_type: type of record, one of {'identity', 'merger'}
+        :param str record_type: type of record, one of {"identity", "merger"}
         """
-        id_prefix = record['concept_id'].split(':')[0].lower()
-        record['src_name'] = PREFIX_LOOKUP[id_prefix]
-        label_and_type = f'{record["concept_id"].lower()}##{record_type}'
-        record['label_and_type'] = label_and_type
-        record['item_type'] = record_type
+        id_prefix = record["concept_id"].split(":")[0].lower()
+        record["src_name"] = PREFIX_LOOKUP[id_prefix]
+        label_and_type = f"{record['concept_id'].lower()}##{record_type}"
+        record["label_and_type"] = label_and_type
+        record["item_type"] = record_type
         try:
             self.batch.put_item(Item=record)
         except ClientError as e:
-            if (len((record['label_and_type']).encode('utf-8')) >= 2048) or \
-                    (len((record['concept_id']).encode('utf-8')) >= 1024):
+            if (len((record["label_and_type"]).encode("utf-8")) >= 2048) or \
+                    (len((record["concept_id"]).encode("utf-8")) >= 1024):
                 logger.info(f"{record['concept_id']}: An error occurred "
-                            "(ValidationException) when calling the "
-                            "BatchWriteItem operation: Hash primary key "
-                            "values must be under 2048 bytes, and range "
-                            "primary key values must be under 1024 "
+                            "(ValidationException) when calling the BatchWriteItem "
+                            "operation: Hash primary key values must be under 2048 "
+                            "bytes, and range primary key values must be under 1024 "
                             "bytes.")
             else:
                 logger.error("boto3 client error on add_record for "
                              f"{record['concept_id']}: "
                              f"{e.response['Error']['Message']}")
 
-    def add_ref_record(self, term: str, concept_id: str, ref_type: str):
+    def add_ref_record(self, term: str, concept_id: str, ref_type: str) -> None:
         """Add auxiliary/reference record to database.
 
         :param str term: referent term
         :param str concept_id: concept ID to refer to
-        :param str ref_type: one of {'alias', 'label', 'trade_name',
-            'xref', 'associated_with', 'rx_brand'}
+        :param str ref_type: one of {"alias", "label", "trade_name",
+            "xref", "associated_with", "rx_brand"}
         """
-        label_and_type = f'{term.lower()}##{ref_type}'
-        src_name = PREFIX_LOOKUP[concept_id.split(':')[0].lower()]
+        label_and_type = f"{term.lower()}##{ref_type}"
+        src_name = PREFIX_LOOKUP[concept_id.split(":")[0].lower()]
         record = {
-            'label_and_type': label_and_type,
-            'concept_id': concept_id.lower(),
-            'src_name': src_name,
-            'item_type': ref_type,
+            "label_and_type": label_and_type,
+            "concept_id": concept_id.lower(),
+            "src_name": src_name,
+            "item_type": ref_type,
         }
         try:
             self.batch.put_item(Item=record)
         except ClientError as e:
-            if (len((record['label_and_type']).encode('utf-8')) >= 2048) or \
-                    (len((record['concept_id']).encode('utf-8')) >= 1024):
+            if (len((record["label_and_type"]).encode("utf-8")) >= 2048) or \
+                    (len((record["concept_id"]).encode("utf-8")) >= 1024):
                 logger.info(f"{record['concept_id']}: An error occurred "
                             "(ValidationException) when calling the "
                             "BatchWriteItem operation: Hash primary key "
@@ -323,20 +320,20 @@ def add_ref_record(self, term: str, concept_id: str, ref_type: str):
                              f"{e.response['Error']['Message']}")
 
     def update_record(self, concept_id: str, field: str, new_value: Any,
-                      item_type: str = 'identity'):
+                      item_type: str = "identity") -> None:
         """Update the field of an individual record to a new value.
 
         :param str concept_id: record to update
         :param str field: name of field to update
         :param Any new_value: new value
-        :param str item_type: record type, one of {'identity', 'merger'}
+        :param str item_type: record type, one of {"identity", "merger"}
         """
         key = {
-            'label_and_type': f'{concept_id.lower()}##identity',
-            'concept_id': concept_id
+            "label_and_type": f"{concept_id.lower()}##{item_type}",
+            "concept_id": concept_id
         }
         update_expression = f"set {field}=:r"
-        update_values = {':r': new_value}
+        update_values = {":r": new_value}
         try:
             self.therapies.update_item(Key=key,
                                        UpdateExpression=update_expression,
@@ -345,7 +342,7 @@ def update_record(self, concept_id: str, field: str, new_value: Any,
             logger.error(f"boto3 client error in `database.update_record()`: "
                          f"{e.response['Error']['Message']}")
 
-    def flush_batch(self):
+    def flush_batch(self) -> None:
         """Flush internal batch_writer."""
         self.batch.__exit__(*sys.exc_info())
         self.batch = self.therapies.batch_writer()
diff --git a/therapy/etl/__init__.py b/therapy/etl/__init__.py
index 055eaa6a..ed4de771 100644
--- a/therapy/etl/__init__.py
+++ b/therapy/etl/__init__.py
@@ -1,9 +1,11 @@
 """Module to load and init namespace at package level"""
-from .wikidata import Wikidata  # noqa: F401
-from .chembl import ChEMBL  # noqa: F401
-from .ncit import NCIt  # noqa: F401
-from .drugbank import DrugBank  # noqa: F401
-from .chemidplus import ChemIDplus  # noqa: F401
-from .rxnorm import RxNorm  # noqa: F401
-from .hemonc import HemOnc  # noqa: F401
-from .merge import Merge  # noqa: F401
+from .chembl import ChEMBL
+from .chemidplus import ChemIDplus
+from .drugbank import DrugBank
+from .drugsatfda import DrugsAtFDA
+from .guidetopharmacology import GuideToPHARMACOLOGY
+from .hemonc import HemOnc
+from .ncit import NCIt
+from .rxnorm import RxNorm
+from .wikidata import Wikidata
+from .merge import Merge
diff --git a/therapy/etl/base.py b/therapy/etl/base.py
index 6c8a0477..3122f8c2 100644
--- a/therapy/etl/base.py
+++ b/therapy/etl/base.py
@@ -1,21 +1,40 @@
 """A base class for extraction, transformation, and loading of data."""
 from abc import ABC, abstractmethod
-from ftplib import FTP
+import ftplib
 from pathlib import Path
-from typing import List, Dict
-from therapy import ACCEPTED_SOURCES, PROJECT_ROOT, ITEM_TYPES
-from therapy.schemas import Drug
 import logging
+from typing import List, Dict, Optional, Callable
+import os
+import zipfile
+import tempfile
+
+from pydantic import ValidationError
+import requests
+import bioversions
+
+from therapy import APP_ROOT, ITEM_TYPES, DownloadException
+from therapy.schemas import Drug
+from therapy.database import Database
 
 
-logger = logging.getLogger('therapy')
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
+DEFAULT_DATA_PATH: Path = APP_ROOT / "data"
+
 
 class Base(ABC):
-    """The ETL base class."""
+    """The ETL base class.
+
+    Default methods are declared to provide basic functions for core source
+    data-gathering phases (extraction, transformation, loading), as well
+    as some common subtasks (getting most recent version, downloading data
+    from an FTP server). Classes should expand or reimplement these methods as
+    needed.
+    """
 
-    def __init__(self, database, data_path=PROJECT_ROOT / 'data'):
+    def __init__(self, database: Database,
+                 data_path: Path = DEFAULT_DATA_PATH) -> None:
         """Extract from sources.
 
         :param Database database: application database object
@@ -23,13 +42,11 @@ def __init__(self, database, data_path=PROJECT_ROOT / 'data'):
         """
         name = self.__class__.__name__.lower()
         self.database = database
-        self._src_data_dir = data_path / name
-        self.in_normalize = name in ACCEPTED_SOURCES
-        self._added_ids = []
+        self._src_dir: Path = Path(data_path / name)
+        self._added_ids: List[str] = []
 
     def perform_etl(self) -> List[str]:
         """Public-facing method to begin ETL procedures on given data.
-
         Returned concept IDs can be passed to Merge method for computing
         merged concepts.
 
@@ -41,93 +58,185 @@ def perform_etl(self) -> List[str]:
         self._transform_data()
         return self._added_ids
 
-    def _download_data(self, *args, **kwargs):
+    def get_latest_version(self) -> str:
+        """Get most recent version of source data. Should be overriden by
+        sources not added to Bioversions yet, or other special-case sources.
+        :return: most recent version, as a str
+        """
+        return bioversions.get_version(self.__class__.__name__)
+
+    @abstractmethod
+    def _download_data(self) -> None:
+        """Acquire source data and deposit in a usable form with correct file
+        naming conventions (generally, `<source>_<version>.<filetype>`, or
+        `<source>_<subset>_<version>.<filetype>` if sources require multiple
+        files). Shouldn't set any instance attributes.
+        """
         raise NotImplementedError
 
-    def _ftp_download(self, host: str, data_dir: str, source_dir: Path,
-                      data_fn: str) -> None:
+    def _zip_handler(self, dl_path: Path, outfile_path: Path) -> None:
+        """Provide simple callback function to extract the largest file within a given
+        zipfile and save it within the appropriate data directory.
+        :param Path dl_path: path to temp data file
+        :param Path outfile_path: path to save file within
+        """
+        with zipfile.ZipFile(dl_path, "r") as zip_ref:
+            if len(zip_ref.filelist) > 1:
+                files = sorted(zip_ref.filelist, key=lambda z: z.file_size,
+                               reverse=True)
+                target = files[0]
+            else:
+                target = zip_ref.filelist[0]
+            target.filename = outfile_path.name
+            zip_ref.extract(target, path=outfile_path.parent)
+        os.remove(dl_path)
+
+    @staticmethod
+    def _http_download(url: str, outfile_path: Path, headers: Optional[Dict] = None,
+                       handler: Optional[Callable[[Path, Path], None]] = None) -> None:
+        """Perform HTTP download of remote data file.
+        :param str url: URL to retrieve file from
+        :param Path outfile_path: path to where file should be saved. Must be an actual
+            Path instance rather than merely a pathlike string.
+        :param Optional[Dict] headers: Any needed HTTP headers to include in request
+        :param Optional[Callable[[Path, Path], None]] handler: provide if downloaded
+            file requires additional action, e.g. it's a zip file.
+        """
+        if handler:
+            dl_path = Path(tempfile.gettempdir()) / "therapy_dl_tmp"
+        else:
+            dl_path = outfile_path
+        # use stream to avoid saving download completely to memory
+        with requests.get(url, stream=True, headers=headers) as r:
+            try:
+                r.raise_for_status()
+            except requests.HTTPError:
+                raise DownloadException(
+                    f"Failed to download {outfile_path.name} from {url}."
+                )
+            with open(dl_path, "wb") as h:
+                for chunk in r.iter_content(chunk_size=8192):
+                    if chunk:
+                        h.write(chunk)
+        if handler:
+            handler(dl_path, outfile_path)
+
+    def _ftp_download(self, host: str, host_dir: str, host_fn: str) -> None:
         """Download data file from FTP site.
         :param str host: Source's FTP host name
-        :param str data_dir: Data directory located on FTP site
-        :param Path source_dir: Source's data directory
-        :param str data_fn: Filename on FTP site to be downloaded
+        :param str host_dir: Data directory located on FTP site
+        :param str host_fn: Filename on FTP site to be downloaded
         """
         try:
-            with FTP(host) as ftp:
+            with ftplib.FTP(host) as ftp:
                 ftp.login()
                 logger.debug(f"FTP login to {host} was successful")
-                ftp.cwd(data_dir)
-                with open(source_dir / data_fn, 'wb') as fp:
-                    ftp.retrbinary(f'RETR {data_fn}', fp.write)
-        except Exception as e:
-            logger.error(f'FTP download failed: {e}')
+                ftp.cwd(host_dir)
+                with open(self._src_dir / host_fn, "wb") as fp:
+                    ftp.retrbinary(f"RETR {host_fn}", fp.write)
+        except ftplib.all_errors as e:
+            logger.error(f"FTP download failed: {e}")
             raise Exception(e)
 
-    def _extract_data(self):
-        """Get source file from data directory."""
-        self._src_data_dir.mkdir(exist_ok=True, parents=True)
-        src_file_prefix = f'{type(self).__name__.lower()}_'
-        dir_files = [f for f in self._src_data_dir.iterdir()
-                     if f.name.startswith(src_file_prefix)]
-        if len(dir_files) == 0:
+    def _extract_data(self) -> None:
+        """Get source file from data directory.
+        This method should create the source data directory if needed,
+        acquire the most recent version number, check that local data is
+        up-to-date and retrieve the latest data if needed, and set the
+        `self._src_file` attribute to the source file location. Child classes
+        could add additional functions, e.g. setting up DB cursors.
+
+        Sources that use multiple data files (such as RxNorm and HemOnc) will
+        have to reimplement this method.
+        """
+        self._src_dir.mkdir(exist_ok=True, parents=True)
+        self._version = self.get_latest_version()
+        fglob = f"{type(self).__name__.lower()}_{self._version}.*"
+        latest = list(self._src_dir.glob(fglob))
+        if not latest:
             self._download_data()
-            dir_files = [f for f in self._src_data_dir.iterdir()
-                         if f.name.startswith(src_file_prefix)]
-        self._src_file = sorted(dir_files, reverse=True)[0]
-        self._version = self._src_file.stem.split('_', 1)[1]
+            latest = list(self._src_dir.glob(fglob))
+        assert len(latest) != 0  # probably unnecessary, but just to be safe
+        self._src_file: Path = latest[0]
 
     @abstractmethod
-    def _transform_data(self, *args, **kwargs):
+    def _load_meta(self) -> None:
+        """Load source metadata entry."""
         raise NotImplementedError
 
-    def _load_therapy(self, therapy: Dict):
-        """Load individual therapy record.
+    @abstractmethod
+    def _transform_data(self) -> None:
+        """Prepare source data for loading into DB. Individually extract each
+        record and call the Base class's `_load_therapy()` method.
+        """
+        raise NotImplementedError
+
+    def _load_therapy(self, therapy: Dict) -> None:
+        """Load individual therapy record into database.
+        Additionally, this method takes responsibility for:
+            * validating record structure correctness
+            * removing duplicates from list-like fields
+            * removing empty fields
 
         :param Dict therapy: valid therapy object.
         """
-        assert Drug(**therapy)
-        concept_id = therapy['concept_id']
+        try:
+            Drug(**therapy)
+        except ValidationError as e:
+            logger.error(f"Attempted to load invalid therapy: {therapy}")
+            raise e
+
+        concept_id = therapy["concept_id"]
 
         for attr_type, item_type in ITEM_TYPES.items():
             if attr_type in therapy:
                 value = therapy[attr_type]
-                if value is not None and value != []:
-                    if isinstance(value, str):
-                        items = [value.lower()]
-                    else:
-                        therapy[attr_type] = list(set(value))
-                        items = {item.lower() for item in value}
-                        if attr_type in ['aliases', 'trade_names']:
-                            # remove duplicates
-                            if 'label' in therapy:
-                                therapy[attr_type] = list(set(therapy[attr_type]) - {therapy['label']})  # noqa: E501
-
-                            if attr_type == 'aliases' and \
-                                    'trade_names' in therapy:
-                                therapy[attr_type] = list(set(therapy[attr_type]) - set(therapy['trade_names']))  # noqa: E501
-
-                            if len(items) > 20:
-                                logger.debug(f"{concept_id} has > 20"
-                                             f" {attr_type}.")
-                                del therapy[attr_type]
-                                continue
-
-                    for item in items:
-                        self.database.add_ref_record(item, concept_id,
-                                                     item_type)
+                if value is None or value == []:
+                    del therapy[attr_type]
+                    continue
+
+                if attr_type == "label":
+                    self.database.add_ref_record(value.lower(),
+                                                 concept_id, item_type)
+                    continue
+
+                # clean up listlike symbol fields
+                if attr_type == "aliases" and "trade_names" in therapy:
+                    value = list(set(value) - set(therapy["trade_names"]))
                 else:
+                    value = list(set(value))
+
+                if attr_type in ("aliases", "trade_names"):
+                    if "label" in therapy:
+                        try:
+                            value.remove(therapy["label"])
+                        except ValueError:
+                            pass
+
+                if len(value) > 20:
+                    logger.debug(f"{concept_id} has > 20 {attr_type}.")
                     del therapy[attr_type]
+                    continue
+
+                for item in {item.lower() for item in value}:
+                    self.database.add_ref_record(item, concept_id, item_type)
+                therapy[attr_type] = value
+
+        # compress has_indication
+        indications = therapy.get("has_indication")
+        if indications:
+            therapy["has_indication"] = [
+                [ind["disease_id"], ind["disease_label"], ind["normalized_disease_id"]]
+                for ind in indications
+            ]
+        elif "has_indication" in therapy:
+            del therapy["has_indication"]
 
         # handle detail fields
-        approval_attrs = ('approval_status', 'approval_year', 'fda_indication')
+        approval_attrs = ("approval_rating", "approval_year")
         for field in approval_attrs:
             if approval_attrs in therapy and therapy[field] is None:
                 del therapy[field]
 
         self.database.add_record(therapy)
-        if self.in_normalize:
-            self._added_ids.append(concept_id)
-
-    @abstractmethod
-    def _load_meta(self, *args, **kwargs):
-        raise NotImplementedError
+        self._added_ids.append(concept_id)
diff --git a/therapy/etl/chembl.py b/therapy/etl/chembl.py
index a2292967..4a52c5ea 100644
--- a/therapy/etl/chembl.py
+++ b/therapy/etl/chembl.py
@@ -1,61 +1,43 @@
 """This module defines the ChEMBL ETL methods."""
-from .base import Base
-from therapy import PROJECT_ROOT
-from therapy.schemas import SourceName, NamespacePrefix, ApprovalStatus, \
-    SourceMeta
 import logging
-import tarfile
-import sqlite3
 import os
 import shutil
+import sqlite3
+
+import chembl_downloader
+import bioversions
+
+from therapy.etl.base import Base
+from therapy.schemas import SourceName, NamespacePrefix, ApprovalRating, \
+    SourceMeta
+
 
-logger = logging.getLogger('therapy')
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class ChEMBL(Base):
-    """ETL the ChEMBL source into therapy.db."""
-
-    def _extract_data(self, *args, **kwargs):
+    """Class for ChEMBL ETL methods."""
+
+    def _download_data(self) -> None:
+        """Download latest ChEMBL database file from EBI."""
+        logger.info("Retrieving source data for ChEMBL")
+        os.environ["PYSTOW_HOME"] = str(self._src_dir.parent.absolute())
+        tmp_path = chembl_downloader.download_extract_sqlite()
+        shutil.move(tmp_path, self._src_dir)
+        shutil.rmtree(tmp_path.parent.parent.parent)
+        logger.info("Successfully retrieved source data for ChEMBL")
+
+    def _extract_data(self) -> None:
         """Extract data from the ChEMBL source."""
-        logger.info('Extracting chembl_27.db...')
-        if 'data_path' in kwargs:
-            chembl_db = kwargs['data_path']
-        else:
-            chembl_db = self._src_data_dir / 'chembl_27.db'
-        if not chembl_db.exists():
-            chembl_archive = self._src_data_dir / 'chembl_27_sqlite.tar.gz'
-            chembl_archive.parent.mkdir(exist_ok=True, parents=True)
-            self._download_data()
-            tar = tarfile.open(chembl_archive)
-            tar.extractall(path=PROJECT_ROOT / 'data' / 'chembl')
-            tar.close()
-
-            # Remove unused directories and files
-            chembl_27_dir = self._src_data_dir / 'chembl_27'
-            temp_chembl = chembl_27_dir / 'chembl_27_sqlite' / 'chembl_27.db'
-            chembl_db = self._src_data_dir / 'chembl_27.db'
-            shutil.move(temp_chembl, chembl_db)
-            os.remove(chembl_archive)
-            shutil.rmtree(chembl_27_dir)
-        conn = sqlite3.connect(chembl_db)
+        super()._extract_data()
+        conn = sqlite3.connect(self._src_file)
         conn.row_factory = sqlite3.Row
         self._conn = conn
         self._cursor = conn.cursor()
-        assert chembl_db.exists()
-        logger.info('Finished extracting chembl_27.db.')
-
-    def _download_data(self, *args, **kwargs):
-        """Download ChEMBL data from FTP."""
-        logger.info(
-            'Downloading ChEMBL v27, this will take a few minutes.')
-        self._ftp_download('ftp.ebi.ac.uk',
-                           'pub/databases/chembl/ChEMBLdb/releases/chembl_27',
-                           self._src_data_dir,
-                           'chembl_27_sqlite.tar.gz')
-
-    def _transform_data(self, *args, **kwargs):
-        """Transform SQLite data to JSON."""
+
+    def _transform_data(self) -> None:
+        """Transform SQLite data to temporary JSON."""
         self._create_dictionary_synonyms_table()
         self._create_trade_names_table()
         self._create_temp_table()
@@ -67,7 +49,7 @@ def _transform_data(self, *args, **kwargs):
         self._conn.commit()
         self._conn.close()
 
-    def _create_dictionary_synonyms_table(self):
+    def _create_dictionary_synonyms_table(self) -> None:
         """Create temporary table to store drugs and their synonyms."""
         create_dictionary_synonyms_table = f"""
             CREATE TEMPORARY TABLE DictionarySynonyms AS
@@ -98,7 +80,7 @@ def _create_dictionary_synonyms_table(self):
         """
         self._cursor.execute(create_dictionary_synonyms_table)
 
-    def _create_trade_names_table(self):
+    def _create_trade_names_table(self) -> None:
         """Create temporary table to store trade name data."""
         create_trade_names_table = """
             CREATE TEMPORARY TABLE TradeNames AS
@@ -114,29 +96,35 @@ def _create_trade_names_table(self):
         """
         self._cursor.execute(create_trade_names_table)
 
-    def _create_temp_table(self):
+    def _create_temp_table(self) -> None:
         """Create temporary table to store therapies data."""
         create_temp_table = """
-            CREATE TEMPORARY TABLE temp(concept_id, label, approval_status,
+            CREATE TEMPORARY TABLE temp(concept_id, label, approval_rating,
                                         src_name, trade_names, aliases);
         """
         self._cursor.execute(create_temp_table)
 
         insert_temp = f"""
-            INSERT INTO temp(concept_id, label, approval_status, src_name,
+            INSERT INTO temp(concept_id, label, approval_rating, src_name,
                              trade_names, aliases)
             SELECT
                 ds.chembl_id,
                 ds.pref_name,
                 CASE
                     WHEN ds.withdrawn_flag
-                        THEN '{ApprovalStatus.WITHDRAWN.value}'
-                    WHEN ds.max_phase == 4
-                        THEN '{ApprovalStatus.APPROVED.value}'
+                        THEN '{ApprovalRating.CHEMBL_WITHDRAWN.value}'
                     WHEN ds.max_phase == 0
-                        THEN NULL
+                        THEN '{ApprovalRating.CHEMBL_0.value}'
+                    WHEN ds.max_phase == 1
+                        THEN '{ApprovalRating.CHEMBL_1.value}'
+                    WHEN ds.max_phase == 2
+                        THEN '{ApprovalRating.CHEMBL_2.value}'
+                    WHEN ds.max_phase ==3
+                        THEN '{ApprovalRating.CHEMBL_3.value}'
+                    WHEN ds.max_phase == 4
+                        THEN '{ApprovalRating.CHEMBL_4.value}'
                     ELSE
-                        '{ApprovalStatus.INVESTIGATIONAL.value}'
+                        '{None}'
                 END,
                 '{SourceName.CHEMBL.value}',
                 t.trade_names,
@@ -150,13 +138,19 @@ def _create_temp_table(self):
                 ds.pref_name,
                 CASE
                     WHEN ds.withdrawn_flag
-                        THEN '{ApprovalStatus.WITHDRAWN.value}'
-                    WHEN ds.max_phase == 4
-                        THEN '{ApprovalStatus.APPROVED.value}'
+                        THEN '{ApprovalRating.CHEMBL_WITHDRAWN.value}'
                     WHEN ds.max_phase == 0
-                        THEN NULL
+                        THEN '{ApprovalRating.CHEMBL_0.value}'
+                    WHEN ds.max_phase == 1
+                        THEN '{ApprovalRating.CHEMBL_1.value}'
+                    WHEN ds.max_phase == 2
+                        THEN '{ApprovalRating.CHEMBL_2.value}'
+                    WHEN ds.max_phase ==3
+                        THEN '{ApprovalRating.CHEMBL_3.value}'
+                    WHEN ds.max_phase == 4
+                        THEN '{ApprovalRating.CHEMBL_4.value}'
                     ELSE
-                        '{ApprovalStatus.INVESTIGATIONAL.value}'
+                        '{None}'
                 END,
                 '{SourceName.CHEMBL.value}',
                 t.trade_names,
@@ -168,13 +162,13 @@ def _create_temp_table(self):
         """
         self._cursor.execute(insert_temp)
 
-    def _load_json(self):
+    def _load_json(self) -> None:
         """Load ChEMBL data into database."""
         chembl_data = """
             SELECT
                 concept_id,
                 label,
-                approval_status,
+                approval_rating,
                 src_name,
                 trade_names,
                 aliases
@@ -185,23 +179,23 @@ def _load_json(self):
         self._cursor.execute("DROP TABLE temp;")
 
         for record in result:
-            for attr in ['aliases', 'trade_names']:
+            for attr in ["aliases", "trade_names"]:
                 if attr in record and record[attr]:
-                    record[attr] = record[attr].split('||')
+                    record[attr] = record[attr].split("||")
             self._load_therapy(record)
 
-    def _load_meta(self, *args, **kwargs):
+    def _load_meta(self) -> None:
         """Add ChEMBL metadata."""
-        metadata = SourceMeta(data_license='CC BY-SA 3.0',
-                              data_license_url='https://creativecommons.org/licenses/by-sa/3.0/',  # noqa: E501
-                              version='27',
-                              data_url='http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/',  # noqa: E501
-                              rdp_url='http://reusabledata.org/chembl.html',
+        metadata = SourceMeta(data_license="CC BY-SA 3.0",
+                              data_license_url="https://creativecommons.org/licenses/by-sa/3.0/",  # noqa: E501
+                              version=self._version,
+                              data_url=bioversions.resolve("chembl").homepage,
+                              rdp_url="http://reusabledata.org/chembl.html",
                               data_license_attributes={
-                                  'non_commercial': False,
-                                  'share_alike': True,
-                                  'attribution': True
+                                  "non_commercial": False,
+                                  "share_alike": True,
+                                  "attribution": True
                               })
         params = dict(metadata)
-        params['src_name'] = SourceName.CHEMBL.value
+        params["src_name"] = SourceName.CHEMBL.value
         self.database.metadata.put_item(Item=params)
diff --git a/therapy/etl/chemidplus.py b/therapy/etl/chemidplus.py
index 7edfbe44..2e534489 100644
--- a/therapy/etl/chemidplus.py
+++ b/therapy/etl/chemidplus.py
@@ -2,168 +2,124 @@
 
 Courtesy of the U.S. National Library of Medicine.
 """
-from .base import Base
-from therapy import PROJECT_ROOT
-from therapy.schemas import Drug, NamespacePrefix, SourceMeta, SourceName, \
-    DataLicenseAttributes
+import logging
+import zipfile
+from os import remove
+from shutil import move
+from typing import Generator
 from pathlib import Path
 import xml.etree.ElementTree as ET
-import logging
 import re
 
+from therapy.etl.base import Base
+from therapy.schemas import NamespacePrefix, SourceMeta, SourceName, \
+    DataLicenseAttributes, RecordParams
+
 
-logger = logging.getLogger('therapy')
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
-TAGS_REGEX = r' \[.*\]'
+TAGS_REGEX = r" \[.*\]"
 
 
 class ChemIDplus(Base):
-    """Core ChemIDplus ETL class."""
-
-    def __init__(self,
-                 database,
-                 data_path: Path = PROJECT_ROOT / 'data',
-                 src_server: str = 'ftp.nlm.nih.gov',
-                 src_dir_path: str = 'nlmdata/.chemidlease/',
-                 src_fname: str = 'CurrentChemID.xml'):
-        """Initialize class instance.
-
-        :param Database database: application database object
-        :param Path data_path: path to app data directory
-        :param str src_server: The NLM domain
-        :param str src_dir_path: The directory to the chemidplus release
-        :param str src_fname: name of file as stored in src_dir.
-
-        If the source file is provided locally in the data_path directory,
-        it's unnecessary to provide `src_dir` and `src_fname` args.
-        """
-        super().__init__(database, data_path)
-        self._src_server = src_server
-        self._src_dir_path = src_dir_path
-        self._src_fname = src_fname
+    """Class for ChemIDplus ETL methods."""
 
-    def _download_data(self):
+    def _download_data(self) -> None:
         """Download source data from default location."""
-        logger.info('Downloading ChemIDplus data...')
-        outfile_path = self._src_data_dir / self._src_fname
-
-        self._ftp_download(self._src_server,
-                           self._src_dir_path,
-                           self._src_data_dir,
-                           self._src_fname)
-
-        parser = ET.iterparse(outfile_path, ('start', 'end'))
-        date = next(parser)[1].attrib['date']
-        version = date.replace('-', '')
-        outfile_path.rename(self._src_data_dir / f'chemidplus_{version}.xml')
-        logger.info('Finished downloading ChemIDplus data')
-
-    def _extract_data(self):
-        """Acquire ChemIDplus dataset.
-
-        :arg pathlib.Path data_path: directory containing source data
-        """
-        self._src_data_dir.mkdir(exist_ok=True, parents=True)
-        dir_files = list(self._src_data_dir.iterdir())
-
-        if len(dir_files) == 0:
-            file = self._get_file()
-        else:
-            file = sorted([f for f in dir_files
-                           if f.name.startswith('chemidplus')])
-            if not file:
-                file = self._get_file()
-
-        self._data_src = file[-1]
-        self._version = self._data_src.stem.split('_')[1]
-
-    def _get_file(self):
-        self._download_data()
-        dir_files = list(self._src_data_dir.iterdir())
-        return sorted([f for f in dir_files
-                       if f.name.startswith('chemidplus')])
+        logger.info("Retrieving source data for ChemIDplus")
+        file = "currentchemid.zip"
+        self._ftp_download("ftp.nlm.nih.gov", "nlmdata/.chemidlease", file)
+        zip_path = (self._src_dir / file).absolute()
+        zip_file = zipfile.ZipFile(zip_path, "r")
+        outfile = self._src_dir / f"chemidplus_{self._version}.xml"
+        for info in zip_file.infolist():
+            if re.match(r".*\.xml", info.filename):
+                xml_filename = info.filename
+                zip_file.extract(info, path=self._src_dir)
+                move(str(self._src_dir / xml_filename), outfile)
+                break
+        remove(zip_path)
+        assert outfile.exists()
+        logger.info("Successfully retrieved source data for ChemIDplus")
 
     @staticmethod
-    def parse_xml(path: Path, tag: str):
+    def parse_xml(path: Path, tag: str) -> Generator:
         """Parse XML file and retrieve elements with matching tag value.
         :param Path path: path to XML file
         :param str tag: XML tag
         :return: generator yielding elements of corresponding tag
         """
-        context = iter(ET.iterparse(path, events=('start', 'end')))
+        context = iter(ET.iterparse(path, events=("start", "end")))
         _, root = next(context)
         for event, elem in context:
-            if event == 'end' and elem.tag == tag:
+            if event == "end" and elem.tag == tag:
                 yield elem
                 root.clear()
 
-    def _transform_data(self):
+    def _transform_data(self) -> None:
         """Open dataset and prepare for loading into database."""
-        parser = self.parse_xml(self._data_src, 'Chemical')
-        for chemical in parser:
-            if 'displayName' not in chemical.attrib:
+        parser = self.parse_xml(self._src_file, "Chemical")
+        for chemical in parser:  # type: ignore
+            if "displayName" not in chemical.attrib:
                 continue
 
             # initial setup and get label
-            display_name = chemical.attrib['displayName']
+            display_name = chemical.attrib["displayName"]
             if not display_name or not re.search(TAGS_REGEX, display_name):
                 continue
-            label = re.sub(TAGS_REGEX, '', display_name)
-            params = {
-                'label': label
-            }
+            label = re.sub(TAGS_REGEX, "", display_name)
+            params: RecordParams = {"label": label}
 
             # get concept ID
-            reg_no = chemical.find('NumberList').find("CASRegistryNumber")
+            reg_no = chemical.find("NumberList").find("CASRegistryNumber")
             if not reg_no:
                 continue
-            params['concept_id'] = \
-                f'{NamespacePrefix.CASREGISTRY.value}:{reg_no.text}'
+            params["concept_id"] = f"{NamespacePrefix.CASREGISTRY.value}:{reg_no.text}"
 
             # get aliases
             aliases = []
             label_l = label.lower()
-            name_list = chemical.find('NameList')
+            name_list = chemical.find("NameList")
             if name_list:
-                for name in name_list.findall('NameOfSubstance'):
+                for name in name_list.findall("NameOfSubstance"):
                     text = name.text
                     if text != display_name and text.lower() != label_l:
-                        aliases.append(re.sub(TAGS_REGEX, '', text))
-            params['aliases'] = aliases
+                        aliases.append(re.sub(TAGS_REGEX, "", text))
+            params["aliases"] = aliases
 
             # get xrefs and associated_with
-            params['xrefs'] = []
-            params['associated_with'] = []
-            locator_list = chemical.find('LocatorList')
+            params["xrefs"] = []
+            params["associated_with"] = []
+            locator_list = chemical.find("LocatorList")
             if locator_list:
-                for loc in locator_list.findall('InternetLocator'):
-                    if loc.text == 'DrugBank':
-                        db = f'{NamespacePrefix.DRUGBANK.value}:' \
-                             f'{loc.attrib["url"].split("/")[-1]}'
-                        params['xrefs'].append(db)
-                    elif loc.text == 'FDA SRS':
-                        fda = f'{NamespacePrefix.FDA.value}:' \
-                              f'{loc.attrib["url"].split("/")[-1]}'
-                        params['associated_with'].append(fda)
-
-            # double-check and load full record
-            assert Drug(**params)
+                for loc in locator_list.findall("InternetLocator"):
+                    if loc.text == "DrugBank":
+                        db = f"{NamespacePrefix.DRUGBANK.value}:" \
+                             f"{loc.attrib['url'].split('/')[-1]}"
+                        params["xrefs"].append(db)  # type: ignore
+                    elif loc.text == "FDA SRS":
+                        unii = f"{NamespacePrefix.UNII.value}:" \
+                               f"{loc.attrib['url'].split('/')[-1]}"
+                        params["associated_with"].append(unii)  # type: ignore
+
             self._load_therapy(params)
 
-    def _load_meta(self):
+    def _load_meta(self) -> None:
         """Add source metadata."""
-        meta = SourceMeta(data_license="custom",
-                          data_license_url="https://www.nlm.nih.gov/databases/download/terms_and_conditions.html",  # noqa: E501
-                          version=self._version,
-                          data_url="ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/",  # noqa: E501
-                          rdp_url=None,
-                          data_license_attributes=DataLicenseAttributes(
-                              non_commercial=False,
-                              share_alike=False,
-                              attribution=True
-                          ))
+        meta = SourceMeta(
+            data_license="custom",
+            data_license_url="https://www.nlm.nih.gov/databases/download/terms_and_conditions.html",  # noqa: E501
+            version=self._version,
+            data_url="ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/",
+            rdp_url=None,
+            data_license_attributes=DataLicenseAttributes(
+                non_commercial=False,
+                share_alike=False,
+                attribution=True
+            )
+        )
         item = dict(meta)
-        item['src_name'] = SourceName.CHEMIDPLUS.value
+        item["src_name"] = SourceName.CHEMIDPLUS.value
         self.database.metadata.put_item(Item=item)
diff --git a/therapy/etl/drugbank.py b/therapy/etl/drugbank.py
index 5bdad20f..accadc98 100644
--- a/therapy/etl/drugbank.py
+++ b/therapy/etl/drugbank.py
@@ -1,117 +1,81 @@
 """This module defines the DrugBank ETL methods."""
-from therapy import DownloadException
-from therapy.schemas import SourceName, SourceMeta, NamespacePrefix
-from therapy.etl.base import Base
+from typing import Dict, Any
 import logging
 import csv
-import bs4
-import requests
-import re
-import zipfile
-import shutil
-from io import BytesIO
 
-logger = logging.getLogger('therapy')
+from therapy.schemas import SourceName, SourceMeta, NamespacePrefix
+from therapy.etl.base import Base
+
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class DrugBank(Base):
-    """ETL the DrugBank source into therapy.db."""
+    """Class for DrugBank ETL methods."""
 
-    def _download_data(self):
+    def _download_data(self) -> None:
         """Download DrugBank source data."""
-        logger.info("Downloading DrugBank source data...")
-
-        # get newest version number
-        r = requests.get('https://go.drugbank.com/release_notes')
-        if r.status_code == 200:
-            soup = bs4.BeautifulSoup(r.content, features='lxml')
-        else:
-            logger.error(f'DrugBank version fetch failed with status code: '
-                         f'{r.status_code}')
-            raise DownloadException
-        most_recent = soup.find('div', {'class': 'card-header'})
-        version = re.search(r'[0-9]+\.[0-9]+\.[0-9]+',
-                            most_recent.contents[0]).group()
-        url = f'https://go.drugbank.com/releases/{version.replace(".", "-")}/downloads/all-drugbank-vocabulary'  # noqa: E501
-
-        # download file
-        r = requests.get(url)
-        if r.status_code == 200:
-            zip_file = zipfile.ZipFile(BytesIO(r.content))
-        else:
-            logger.error("DrugBank download failed with status code:"
-                         f" {r.status_code}")
-            raise requests.HTTPError(r.status_code)
-
-        # unpack file
-        temp_dir = self._src_data_dir / 'temp_drugbank'
-        zip_file.extractall(temp_dir)
-        temp_file = temp_dir / 'drugbank vocabulary.csv'
-        csv_file = self._src_data_dir / f'drugbank_{version}.csv'
-        shutil.move(temp_file, csv_file)
-        shutil.rmtree(temp_dir)
-        logger.info("DrugBank source data download complete.")
+        logger.info("Retrieving source data for DrugBank")
+        url = f"https://go.drugbank.com/releases/{self._version.replace('.', '-')}/downloads/all-drugbank-vocabulary"  # noqa: E501
+        csv_file = self._src_dir / f"drugbank_{self._version}.csv"
+        self._http_download(url, csv_file, handler=self._zip_handler)
+        logger.info("Successfully retrieved source data for DrugBank")
 
-    def _load_meta(self):
+    def _load_meta(self) -> None:
         """Add DrugBank metadata."""
         meta = {
-            'data_license': 'CC0 1.0',
-            'data_license_url': 'https://creativecommons.org/publicdomain/zero/1.0/',  # noqa: E501
-            'version': self._version,
-            'data_url': 'https://go.drugbank.com/releases/latest#open-data',
-            'rdp_url': 'http://reusabledata.org/drugbank.html',
-            'data_license_attributes': {
-                'non_commercial': False,
-                'share_alike': False,
-                'attribution': False,
+            "data_license": "CC0 1.0",
+            "data_license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
+            "version": self._version,
+            "data_url": "https://go.drugbank.com/releases/latest#open-data",
+            "rdp_url": "http://reusabledata.org/drugbank.html",
+            "data_license_attributes": {
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": False,
             },
         }
         assert SourceMeta(**meta)
-        meta['src_name'] = SourceName.DRUGBANK.value
+        meta["src_name"] = SourceName.DRUGBANK.value
         self.database.metadata.put_item(Item=meta)
 
-    def _transform_data(self):
+    def _transform_data(self) -> None:
         """Transform the DrugBank source."""
-        with open(self._src_file, 'r') as file:
+        with open(self._src_file, "r") as file:
             reader = csv.reader(file)
             next(reader)  # skip header
             for row in reader:
                 # get concept ID
-                params = {
-                    'concept_id': f'{NamespacePrefix.DRUGBANK.value}:{row[0]}',
+                params: Dict[str, Any] = {
+                    "concept_id": f"{NamespacePrefix.DRUGBANK.value}:{row[0]}",
                 }
 
                 # get label
                 label = row[2]
                 if label:
-                    params['label'] = label
+                    params["label"] = label
 
                 # get aliases
-                aliases = [
-                    a for a in row[1].split(' | ') + row[5].split(' | ') if a
-                ]
+                aliases = [a for a in row[1].split(" | ") + row[5].split(" | ") if a]
                 if aliases:
-                    params['aliases'] = aliases
+                    params["aliases"] = aliases
 
                 # get CAS reference
                 cas_ref = row[3]
                 if cas_ref:
-                    params['xrefs'] = [
-                        f'{NamespacePrefix.CHEMIDPLUS.value}:{row[3]}'
-                    ]
+                    params["xrefs"] = [f"{NamespacePrefix.CHEMIDPLUS.value}:{row[3]}"]
 
-                params['associated_with'] = []
+                params["associated_with"] = []
                 # get inchi key
                 if len(row) >= 7:
                     inchi_key = row[6]
                     if inchi_key:
-                        inchi_id = f'{NamespacePrefix.INCHIKEY.value}:{inchi_key}'  # noqa: E501
-                        params['associated_with'].append(inchi_id)
+                        inchi_id = f"{NamespacePrefix.INCHIKEY.value}:{inchi_key}"
+                        params["associated_with"].append(inchi_id)
                 # get UNII id
                 unii = row[4]
                 if unii:
-                    unii_id = f'{NamespacePrefix.UNII.value}:{unii}'
-                    params['associated_with'].append(unii_id)
+                    unii_id = f"{NamespacePrefix.UNII.value}:{unii}"
+                    params["associated_with"].append(unii_id)
 
                 self._load_therapy(params)
diff --git a/therapy/etl/drugsatfda.py b/therapy/etl/drugsatfda.py
new file mode 100644
index 00000000..3f5f872e
--- /dev/null
+++ b/therapy/etl/drugsatfda.py
@@ -0,0 +1,159 @@
+"""ETL methods for the Drugs@FDA source."""
+from typing import List, Optional
+import json
+
+import requests
+
+from therapy import DownloadException, logger
+from therapy.schemas import SourceMeta, SourceName, NamespacePrefix, ApprovalRating, \
+    RecordParams
+from therapy.etl.base import Base
+
+
+class DrugsAtFDA(Base):
+    """Class for Drugs@FDA ETL methods."""
+
+    def _download_data(self) -> None:
+        """Download source data from instance-provided source URL."""
+        logger.info("Retrieving source data for Drugs@FDA")
+        url = "https://download.open.fda.gov/drug/drugsfda/drug-drugsfda-0001-of-0001.json.zip"  # noqa: E501
+        outfile_path = self._src_dir / f"drugsatfda_{self._version}.json"
+        self._http_download(url, outfile_path, handler=self._zip_handler)
+        logger.info("Successfully retrieved source data for Drugs@FDA")
+
+    def get_latest_version(self) -> str:
+        """Retrieve latest version of source data.
+        :return: version as a str -- expected formatting is YYYY-MM-DD
+        """
+        r = requests.get("https://api.fda.gov/download.json")
+        if r.status_code == 200:
+            r_json = r.json()
+            try:
+                date = r_json["results"]["drug"]["drugsfda"]["export_date"]
+            except KeyError:
+                msg = "Unable to parse OpenFDA version API - check for breaking changes"
+                logger.error(msg)
+                raise DownloadException(msg)
+            return date
+        else:
+            raise requests.HTTPError("Unable to retrieve version from FDA API")
+
+    def _load_meta(self) -> None:
+        """Add Drugs@FDA metadata."""
+        meta = {
+            "data_license": "CC0",
+            "data_license_url": "https://creativecommons.org/publicdomain/zero/1.0/legalcode",  # noqa: E501
+            "version": self._version,
+            "data_url": "https://open.fda.gov/apis/drug/drugsfda/download/",
+            "rdp_url": None,
+            "data_license_attributes": {
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": False,
+            }
+        }
+        assert SourceMeta(**meta)
+        meta["src_name"] = SourceName.DRUGSATFDA
+        self.database.metadata.put_item(Item=meta)
+
+    def _get_marketing_status_rating(self, products: List, concept_id: str)\
+            -> Optional[str]:
+        """Get approval status rating from products list.
+        :param List products: list of individual FDA product objects
+        :param str concept_id: FDA application concept ID, used in reporting error
+            messages
+        :return: approval_rating value if successful, None if ambiguous or unavailable
+        """
+        statuses_map = {
+            "Discontinued": ApprovalRating.FDA_DISCONTINUED.value,
+            "Prescription": ApprovalRating.FDA_PRESCRIPTION.value,
+            "Over-the-counter": ApprovalRating.FDA_OTC.value,
+            "None (Tentative Approval)": ApprovalRating.FDA_TENTATIVE.value,
+        }
+        statuses = [p["marketing_status"] for p in products]
+        if not all([s == statuses[0] for s in statuses]):
+            msg = (f"Application {concept_id} has inconsistent marketing "
+                   f"statuses: {statuses}")
+            logger.info(msg)
+            return None
+        else:
+            return statuses_map.get(statuses[0])
+
+    def _transform_data(self) -> None:
+        """Prepare source data for loading into DB."""
+        with open(self._src_file, "r") as f:
+            data = json.load(f)["results"]
+
+        for result in data:
+            if "products" not in result:
+                continue
+            products = result["products"]
+
+            app_no = result["application_number"]
+            if app_no.startswith("NDA"):
+                namespace = NamespacePrefix.DRUGSATFDA_NDA.value
+            elif app_no.startswith("ANDA"):
+                namespace = NamespacePrefix.DRUGSATFDA_ANDA.value
+            else:
+                # ignore biologics license applications (tentative)
+                continue
+            concept_id = f"{namespace}:{app_no.split('NDA')[-1]}"
+            therapy: RecordParams = {"concept_id": concept_id}
+
+            rating = self._get_marketing_status_rating(products, concept_id)
+            if rating:
+                therapy["approval_rating"] = rating
+
+            brand_names = [p["brand_name"] for p in products]
+
+            aliases = []
+            if "openfda" in result:
+                openfda = result["openfda"]
+                brand_name = openfda.get("brand_name")
+                if brand_name:
+                    brand_names += brand_name
+
+                substances = openfda.get("substance_name", [])
+                n_substances = len(set(substances))
+                if n_substances > 1:
+                    # if ambiguous, store all as aliases
+                    msg = (f"Application {concept_id} has {n_substances} "
+                           f"substance names: {substances}")
+                    logger.debug(msg)
+                    aliases += substances
+                elif n_substances == 1:
+                    therapy["label"] = substances[0]
+
+                generics = openfda.get("generic_name", [])
+                n_generic = len(set(generics))
+                if n_generic > 1:
+                    aliases += generics
+                elif n_generic == 1:
+                    if n_substances == 0:
+                        # there are about 300 cases of this
+                        therapy["label"] = generics[0]
+                    else:
+                        aliases.append(generics[0])
+
+                therapy["associated_with"] = []
+                unii = openfda.get("unii")
+                if unii:
+                    unii_items = [f"{NamespacePrefix.UNII.value}:{u}" for u in unii]
+                    therapy["associated_with"] += unii_items  # type: ignore
+                spl = openfda.get("spl_id")
+                if spl:
+                    spl_items = [f"{NamespacePrefix.SPL.value}:{s}" for s in spl]
+                    therapy["associated_with"] += spl_items  # type: ignore
+                ndc = openfda.get("product_ndc")
+                if ndc:
+                    ndc_items = [f"{NamespacePrefix.NDC.value}:{n}" for n in ndc]
+                    therapy["associated_with"] += ndc_items  # type: ignore
+
+                rxcui = openfda.get("rxcui")
+                if rxcui:
+                    therapy["xrefs"] = [f"{NamespacePrefix.RXNORM.value}:{r}"
+                                        for r in rxcui]
+
+            therapy["trade_names"] = brand_names
+            therapy["aliases"] = aliases
+            self._load_therapy(therapy)
diff --git a/therapy/etl/guidetopharmacology.py b/therapy/etl/guidetopharmacology.py
new file mode 100644
index 00000000..56c999aa
--- /dev/null
+++ b/therapy/etl/guidetopharmacology.py
@@ -0,0 +1,191 @@
+"""Module for Guide to PHARMACOLOGY ETL methods."""
+from typing import Optional, Dict, Any, List, Union
+import csv
+import html
+from pathlib import Path
+
+import requests
+
+from therapy import logger
+from therapy.schemas import SourceMeta, SourceName, NamespacePrefix, ApprovalRating
+from therapy.etl.base import Base
+
+
+class GuideToPHARMACOLOGY(Base):
+    """Class for Guide to PHARMACOLOGY ETL methods."""
+
+    def _download_data(self) -> None:
+        """Download the latest version of Guide to PHARMACOLOGY."""
+        logger.info("Retrieving source data for Guide to PHARMACOLOGY")
+        if not self._ligands_file.exists():
+            self._http_download("https://www.guidetopharmacology.org/DATA/ligands.tsv",
+                                self._ligands_file)
+            assert self._ligands_file.exists()
+        if not self._mapping_file.exists():
+            self._http_download("https://www.guidetopharmacology.org/DATA/ligand_id_mapping.tsv",  # noqa: E501
+                                self._mapping_file)
+            assert self._mapping_file.exists()
+        logger.info("Successfully retrieved source data for Guide to PHARMACOLOGY")
+
+    def _download_file(self, file_url: str, fn: str) -> None:
+        """Download individual data file.
+
+        :param str file_url: Data url for file
+        :param str fn: File name
+        """
+        r = requests.get(file_url)
+        if r.status_code == 200:
+            prefix = SourceName.GUIDETOPHARMACOLOGY.value.lower()
+            path = self._src_dir / f"{prefix}_{fn}_{self._version}.tsv"
+            if fn == "ligands":
+                self._ligands_file: Path = path
+            else:
+                self._mapping_file: Path = path
+            with open(str(path), "wb") as f:
+                f.write(r.content)
+
+    def _extract_data(self) -> None:
+        """Gather GtoPdb source files."""
+        self._src_dir.mkdir(exist_ok=True, parents=True)
+        self._version = self.get_latest_version()
+        prefix = SourceName.GUIDETOPHARMACOLOGY.value.lower()
+        self._ligands_file = self._src_dir / f"{prefix}_ligands_{self._version}.tsv"
+        self._mapping_file = self._src_dir / f"{prefix}_ligand_id_mapping_{self._version}.tsv"  # noqa: E501
+        if not (self._ligands_file.exists() and self._mapping_file.exists()):
+            self._download_data()
+            assert self._ligands_file.exists()
+            assert self._mapping_file.exists()
+
+    def _transform_data(self) -> None:
+        """Transform Guide To PHARMACOLOGY data."""
+        data: Dict[str, Any] = dict()
+        self._transform_ligands(data)
+        self._transform_ligand_id_mappings(data)
+        for param in data.values():
+            self._load_therapy(param)
+
+    def _transform_ligands(self, data: Dict) -> None:
+        """Transform ligands data file and add this data to `data`.
+
+        :param dict data: Transformed data
+        """
+        with open(self._ligands_file, "r") as f:
+            rows = csv.reader(f, delimiter="\t")
+            next(rows)
+
+            for row in rows:
+                params: Dict[str, Union[List[str], str]] = {
+                    "concept_id":
+                        f"{NamespacePrefix.GUIDETOPHARMACOLOGY.value}:{row[0]}",
+                    "label": row[1],
+                    "src_name": SourceName.GUIDETOPHARMACOLOGY.value
+                }
+
+                approval_rating = self._set_approval_rating(row[4], row[5])
+                if approval_rating:
+                    params["approval_rating"] = approval_rating
+
+                associated_with = list()
+                aliases = list()
+                if row[8]:
+                    associated_with.append(f"{NamespacePrefix.PUBCHEMSUBSTANCE.value}:{row[8]}")  # noqa: E501
+                if row[9]:
+                    associated_with.append(f"{NamespacePrefix.PUBCHEMCOMPOUND.value}:{row[9]}")  # noqa: E501
+                if row[10]:
+                    associated_with.append(f"{NamespacePrefix.UNIPROT.value}:{row[10]}")
+                if row[11]:
+                    # IUPAC
+                    aliases.append(row[11])
+                if row[12]:
+                    # International Non-proprietary Name assigned by the WHO
+                    aliases.append(row[12])
+                if row[13]:
+                    # synonyms
+                    synonyms = row[13].split("|")
+                    for s in synonyms:
+                        if "&" in s and ";" in s:
+                            name_code = s[s.index("&"):s.index(";") + 1]
+                            if name_code.lower() in ["&reg;", "&trade;"]:
+                                # Remove trademark symbols to allow for search
+                                s = s.replace(name_code, "")
+                            s = html.unescape(s)
+                        aliases.append(s)
+                if row[15]:
+                    associated_with.append(f"{NamespacePrefix.INCHIKEY.value}:{row[15]}")  # noqa: E501
+
+                if associated_with:
+                    params["associated_with"] = associated_with
+                if aliases:
+                    params["aliases"] = aliases
+
+                data[params["concept_id"]] = params
+
+    def _transform_ligand_id_mappings(self, data: Dict) -> None:
+        """Transform ligand_id_mappings and add this data to `data`
+        All ligands found in this file should already be in data
+
+        :param dict data: Transformed data
+        """
+        with open(self._mapping_file.absolute(), "r") as f:
+            rows = csv.reader(f, delimiter="\t")
+            for row in rows:
+                concept_id = f"{NamespacePrefix.GUIDETOPHARMACOLOGY.value}:{row[0]}"
+
+                if concept_id not in data:
+                    logger.debug(f"{concept_id} not in ligands")
+                    continue
+                params = data[concept_id]
+                xrefs = list()
+                associated_with = params.get("associated_with", [])
+                if row[6]:
+                    xrefs.append(f"{NamespacePrefix.CHEMBL.value}:{row[6]}")
+                if row[7]:
+                    # CHEBI
+                    associated_with.append(row[7])
+                if row[11]:
+                    xrefs.append(f"{NamespacePrefix.CASREGISTRY.value}:{row[11]}")
+                if row[12]:
+                    xrefs.append(f"{NamespacePrefix.DRUGBANK.value}:{row[12]}")
+                if row[13]:
+                    associated_with.append(f"{NamespacePrefix.DRUGCENTRAL.value}:{row[13]}")  # noqa: E501
+
+                if xrefs:
+                    params["xrefs"] = xrefs
+                if associated_with:
+                    params["associated_with"] = associated_with
+
+    def _set_approval_rating(self, approved: str,
+                             withdrawn: str) -> Optional[str]:
+        """Set approval rating value.
+
+        :param str approved: The drug is or has in the past been approved for human
+            clinical use by a regulatory agency
+        :param str withdrawn: The drug is no longer approved for its original clinical
+            use in one or more countries
+        :return: Approval rating
+        """
+        if approved and not withdrawn:
+            approval_rating: Optional[str] = ApprovalRating.GTOPDB_APPROVED.value
+        elif withdrawn:
+            approval_rating = ApprovalRating.GTOPDB_WITHDRAWN.value
+        else:
+            approval_rating = None
+        return approval_rating
+
+    def _load_meta(self) -> None:
+        """Load Guide to PHARMACOLOGY metadata to database."""
+        meta = SourceMeta(
+            data_license="CC BY-SA 4.0",
+            data_license_url="https://creativecommons.org/licenses/by-sa/4.0/",
+            version=self._version,
+            data_url="https://www.guidetopharmacology.org/download.jsp",
+            rdp_url=None,
+            data_license_attributes={
+                "non_commercial": False,
+                "share_alike": True,
+                "attribution": True,
+            }
+        )
+        params = dict(meta)
+        params["src_name"] = SourceName.GUIDETOPHARMACOLOGY.value
+        self.database.metadata.put_item(Item=params)
diff --git a/therapy/etl/hemonc.py b/therapy/etl/hemonc.py
index 3dc82235..484e4db2 100644
--- a/therapy/etl/hemonc.py
+++ b/therapy/etl/hemonc.py
@@ -1,23 +1,31 @@
 """Provide ETL methods for HemOnc.org data."""
-from therapy import DownloadException, PROJECT_ROOT
-from therapy.etl.base import Base
-from therapy.schemas import NamespacePrefix, SourceMeta, SourceName, \
-    ApprovalStatus
-from disease.query import QueryHandler as DiseaseNormalizer
+import logging
 from pathlib import Path
 from typing import Dict, Tuple
 import csv
-import logging
+import os
+import zipfile
+
+import requests
+import isodate
+from disease.query import QueryHandler as DiseaseNormalizer
+
+from therapy import DownloadException
+from therapy.database import Database
+from therapy.schemas import NamespacePrefix, SourceMeta, SourceName, RecordParams, \
+    ApprovalRating
+from therapy.etl.base import Base, DEFAULT_DATA_PATH
 
 
-logger = logging.getLogger('therapy')
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class HemOnc(Base):
-    """Docstring"""
+    """Class for HemOnc.org ETL methods."""
 
-    def __init__(self, database, data_path: Path = PROJECT_ROOT / 'data'):
+    def __init__(self, database: Database,
+                 data_path: Path = DEFAULT_DATA_PATH):
         """Initialize HemOnc instance.
 
         :param therapy.database.Database database: application database
@@ -26,57 +34,105 @@ def __init__(self, database, data_path: Path = PROJECT_ROOT / 'data'):
         super().__init__(database, data_path)
         self.disease_normalizer = DiseaseNormalizer(self.database.endpoint_url)
 
-    def _download_data(self):
-        """Download HemOnc.org source data.
+    def get_latest_version(self) -> str:
+        """Retrieve latest version of source data.
+        :raise: Exception if retrieval is unsuccessful
+        """
+        response = requests.get("https://dataverse.harvard.edu/api/datasets/export?persistentId=doi:10.7910/DVN/9CY9C6&exporter=dataverse_json")  # noqa: E501
+        try:
+            response.raise_for_status()
+        except requests.HTTPError as e:
+            logger.error("Unable to retrieve HemOnc version from Harvard Dataverse")
+            raise e
+        iso_datetime = isodate.parse_datetime(
+            response.json()["datasetVersion"]["releaseTime"]
+        )
+        return iso_datetime.strftime(isodate.isostrf.DATE_EXT_COMPLETE)
+
+    def _zip_handler(self, dl_path: Path, outfile_path: Path) -> None:
+        """Extract concepts, rels, and synonyms files from tmp zip file and save to
+        data directory.
+        :param Path dl_path: path to temp data zipfile
+        :param Path outfile_path: directory to save data within
+        """
+        file_terms = ("concepts", "rels", "synonyms")
+        with zipfile.ZipFile(dl_path, "r") as zip_ref:
+            for file in zip_ref.filelist:
+                for term in file_terms:
+                    if term in file.filename:
+                        file.filename = f"hemonc_{term}_{self._version}.csv"
+                        zip_ref.extract(file, outfile_path)
+
+        os.remove(dl_path)
+
+    def _download_data(self) -> None:
+        """Download HemOnc.org source data. Requires Harvard Dataverse API key to be set
+        as environment variable DATAVERSE_API_KEY. Instructions for generating an API
+        key are available here: https://guides.dataverse.org/en/latest/user/account.html
+
+        :raises: DownloadException if API key environment variable isn't set
+        """
+        api_key = os.environ.get("DATAVERSE_API_KEY")
+        if api_key is None:
+            raise DownloadException(
+                "Must provide Harvard Dataverse API key in environment variable "
+                "DATAVERSE_API_KEY. See "
+                "https://guides.dataverse.org/en/latest/user/account.html"
+            )
+        url = "https://dataverse.harvard.edu//api/access/dataset/:persistentId/?persistentId=doi:10.7910/DVN/9CY9C6"  # noqa: E501
+        headers = {"X-Dataverse-key": api_key}
+        self._http_download(url, self._src_dir, headers, self._zip_handler)
+
+    def _extract_data(self) -> None:
+        """Get source files from data directory.
 
-        Raises download exception for now -- HTTP authorization may be
-        possible?
+        The following files are necessary for data processing:
+            hemonc_concepts_<version>.csv
+            hemonc_rels_<version>.csv
+            hemonc_synonyms_<version>.csv
+        This method will attempt to retrieve their latest versions if they are
+        unavailable locally.
         """
-        raise DownloadException("No download for HemOnc data available -- "
-                                "must place manually in data/ directory.")
-
-    def _extract_data(self):
-        """Get source files from data directory."""
-        self._src_data_dir.mkdir(exist_ok=True, parents=True)
-        self._src_files = []
-        for item_type in ('concepts', 'rels', 'synonyms'):
-            src_file_prefix = f'hemonc_{item_type}_'
-            dir_files = [f for f in self._src_data_dir.iterdir()
-                         if f.name.startswith(src_file_prefix)]
-            if len(dir_files) == 0:
-                self._download_data()
-                dir_files = [f for f in self._src_data_dir.iterdir()
-                             if f.name.startswith(src_file_prefix)]
-            self._src_files.append(sorted(dir_files, reverse=True)[0])
-        self._version = self._src_files[0].stem.split('_', 2)[-1]
-
-    def _load_meta(self):
+        self._src_dir.mkdir(exist_ok=True, parents=True)
+        self._version = self.get_latest_version()
+        data_filenames = (
+            self._src_dir / f"hemonc_concepts_{self._version}.csv",
+            self._src_dir / f"hemonc_rels_{self._version}.csv",
+            self._src_dir / f"hemonc_synonyms_{self._version}.csv"
+        )
+        if not all((f.exists() for f in data_filenames)):
+            self._download_data()
+        self._src_files = data_filenames
+        for file in self._src_files:
+            assert file.exists()
+
+    def _load_meta(self) -> None:
         """Add HemOnc metadata."""
         meta = {
-            'data_license': 'CC BY 4.0',
-            'data_license_url': 'https://creativecommons.org/licenses/by/4.0/legalcode',  # noqa: E501
-            'version': self._version,
-            'data_url': 'https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/9CY9C6',  # noqa: E501
-            'rdp_url': None,
-            'data_license_attributes': {
-                'non_commercial': False,
-                'share_alike': False,
-                'attribution': True,
+            "data_license": "CC BY 4.0",
+            "data_license_url": "https://creativecommons.org/licenses/by/4.0/legalcode",
+            "version": self._version,
+            "data_url": "https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/9CY9C6",  # noqa: E501
+            "rdp_url": None,
+            "data_license_attributes": {
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": True,
             },
         }
         assert SourceMeta(**meta)
-        meta['src_name'] = SourceName.HEMONC.value
+        meta["src_name"] = SourceName.HEMONC.value
         self.database.metadata.put_item(Item=meta)
 
     def _get_concepts(self) -> Tuple[Dict, Dict, Dict]:
         """Get therapy, brand name, and disease concepts from concepts file.
         :return: Tuple of dicts mapping ID to object for each type of concept
         """
-        therapies = {}  # hemonc id -> record
-        brand_names = {}  # hemonc id -> brand name
-        conditions = {}  # hemonc id -> condition name
+        therapies: RecordParams = {}  # hemonc id -> record
+        brand_names: Dict[str, str] = {}  # hemonc id -> brand name
+        conditions: Dict[str, str] = {}  # hemonc id -> condition name
 
-        concepts_file = open(self._src_files[0], 'r')
+        concepts_file = open(self._src_files[0], "r")
         concepts_reader = csv.reader(concepts_file)
         next(concepts_reader)  # skip header
         for row in concepts_reader:
@@ -84,18 +140,18 @@ def _get_concepts(self) -> Tuple[Dict, Dict, Dict]:
                 continue  # skip if deprecated/invalid
 
             row_type = row[2]
-            if row_type == 'Component':
-                concept_id = f'{NamespacePrefix.HEMONC.value}:{row[3]}'
+            if row_type == "Component":
+                concept_id = f"{NamespacePrefix.HEMONC.value}:{row[3]}"
                 therapies[row[3]] = {
-                    'concept_id': concept_id,
-                    'label': row[0],
-                    'trade_names': [],
-                    'aliases': [],
-                    'xrefs': [],
+                    "concept_id": concept_id,
+                    "label": row[0],
+                    "trade_names": [],
+                    "aliases": [],
+                    "xrefs": [],
                 }
-            elif row_type == 'Brand Name':
+            elif row_type == "Brand Name":
                 brand_names[row[3]] = row[0]
-            elif row_type == 'Condition':
+            elif row_type == "Condition":
                 conditions[row[3]] = row[0]
         concepts_file.close()
 
@@ -105,8 +161,7 @@ def _get_concepts(self) -> Tuple[Dict, Dict, Dict]:
     def _id_to_yr(hemonc_id: str) -> int:
         """Get year from HemOnc ID corresponding to year concept.
         :param str hemonc_id: HemOnc ID to get year for
-        :return: int representing year. Raises TypeError if HemOnc ID not
-            valid.
+        :return: int representing year. Raises TypeError if HemOnc ID not valid.
         """
         id_int = int(hemonc_id)
         if id_int == 780:
@@ -122,15 +177,15 @@ def _id_to_yr(hemonc_id: str) -> int:
 
     def _get_rels(self, therapies: Dict, brand_names: Dict,
                   conditions: Dict) -> Dict:
-        """Gather relations to provide associations between therapies, brand
-        names, and conditions.
+        """Gather relations to provide associations between therapies, brand names,
+        and conditions.
 
         :param dict therapies: mapping from IDs to therapy concepts
         :param dict brand_names: mapping from IDs to therapy brand names
         :param dict conditions: mapping from IDs to disease conditions
         :return: therapies dict updated with brand names and conditions
         """
-        rels_file = open(self._src_files[1], 'r')
+        rels_file = open(self._src_files[1], "r")
         rels_reader = csv.reader(rels_file)
         next(rels_reader)  # skip header
 
@@ -145,15 +200,15 @@ def _get_rels(self, therapies: Dict, brand_names: Dict,
             if rel_type == "Maps to":
                 src_raw = row[3]
                 if src_raw == "RxNorm":
-                    xref = f'{NamespacePrefix.RXNORM.value}:{row[1]}'
-                    record['xrefs'].append(xref)
+                    xref = f"{NamespacePrefix.RXNORM.value}:{row[1]}"
+                    record["xrefs"].append(xref)
                 elif src_raw == "RxNorm Extension":
                     continue  # skip
                 else:
                     logger.warning(f"Unrecognized `Maps To` source: {src_raw}")
 
             elif rel_type == "Has brand name":
-                record['trade_names'].append(brand_names[row[1]])
+                record["trade_names"].append(brand_names[row[1]])
 
             elif rel_type == "Was FDA approved yr":
                 try:
@@ -165,27 +220,31 @@ def _get_rels(self, therapies: Dict, brand_names: Dict,
                 if year == 9999:
                     logger.warning(f"HemOnc ID {row[0]} has FDA approval year"
                                    f" 9999")
-                record['approval_status'] = ApprovalStatus.APPROVED
-                if 'approval_year' in record:
-                    record['approval_year'].append(year)
+                record["approval_rating"] = ApprovalRating.HEMONC_APPROVED.value
+                if "approval_year" in record:
+                    record["approval_year"].append(year)
                 else:
-                    record['approval_year'] = [year]
+                    record["approval_year"] = [year]
 
             elif rel_type == "Has FDA indication":
                 label = conditions[row[1]]
                 norm_response = self.disease_normalizer.search_groups(label)
-                if norm_response['match_type'] > 0:
-                    ncit_id = norm_response['disease_descriptor']['disease_id']
+                if norm_response["match_type"] > 0:
+                    ncit_id = norm_response["disease_descriptor"]["disease_id"]
                 else:
                     ncit_id = ""
-                    logger.warning(f'Normalization of condition id: {row[1]}'
-                                   f', {label}, failed.')
-                hemonc_concept_id = f"{NamespacePrefix.HEMONC.value}:{row[1]}"  # noqa: E501
-                indication = [hemonc_concept_id, label, ncit_id]
-                if 'fda_indication' in record:
-                    record['fda_indication'].append(indication)
+                    logger.warning(f"Normalization of condition id: {row[1]}"
+                                   f", {label}, failed.")
+                hemonc_concept_id = f"{NamespacePrefix.HEMONC.value}:{row[1]}"
+                indication = {
+                    "disease_id": hemonc_concept_id,
+                    "disease_label": label,
+                    "normalized_disease_id": ncit_id
+                }
+                if "has_indication" in record:
+                    record["has_indication"].append(indication)
                 else:
-                    record['fda_indication'] = [indication]
+                    record["has_indication"] = [indication]
 
         rels_file.close()
         return therapies
@@ -196,7 +255,7 @@ def _get_synonyms(self, therapies: Dict) -> Dict:
         :param dict therapies: mapping of IDs to therapy objects
         :return: therapies dict with synonyms added as aliases
         """
-        synonyms_file = open(self._src_files[2], 'r')
+        synonyms_file = open(self._src_files[2], "r")
         synonyms_reader = csv.reader(synonyms_file)
         next(synonyms_reader)
         for row in synonyms_reader:
@@ -204,12 +263,12 @@ def _get_synonyms(self, therapies: Dict) -> Dict:
             if therapy_code in therapies:
                 therapy = therapies[therapy_code]
                 alias = row[0]
-                if alias != therapy.get('label'):
-                    therapies[therapy_code]['aliases'].append(row[0])
+                if alias != therapy.get("label"):
+                    therapies[therapy_code]["aliases"].append(row[0])
         synonyms_file.close()
         return therapies
 
-    def _transform_data(self):
+    def _transform_data(self) -> None:
         """Prepare dataset for loading into normalizer database."""
         therapies, brand_names, conditions = self._get_concepts()
         therapies = self._get_rels(therapies, brand_names, conditions)
diff --git a/therapy/etl/merge.py b/therapy/etl/merge.py
index 8d1907de..50342896 100644
--- a/therapy/etl/merge.py
+++ b/therapy/etl/merge.py
@@ -1,36 +1,47 @@
 """Create concept groups and merged records."""
-from therapy import PROHIBITED_SOURCES
-from therapy.schemas import SourcePriority
-from therapy.database import Database
-from typing import Set, Dict
+from typing import Set, Dict, Optional, Any, Tuple
 import logging
 from timeit import default_timer as timer
 
-logger = logging.getLogger('therapy')
+from therapy.schemas import SourcePriority
+from therapy.database import Database
+
+
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class Merge:
     """Handles record merging."""
 
-    def __init__(self, database: Database):
+    def __init__(self, database: Database) -> None:
         """Initialize Merge instance.
 
-        :param Database database: db instance to use for record retrieval
-            and creation.
+        * self._groups is a dictionary keying concept IDs to the Set of concept IDs in
+        that group. This is redundantly captured for all group members (i.e., a group
+        of 5 concepts would all have their own key, and the value would be an identical
+        Set of the same IDs)
+        * self._unii_to_drugsatfda keys UNII codes to valid Drugs@FDA concepts.
+        Because UNIIs aren't stored directly in groups, we utilize a separate lookup
+        table to prevent repeat queries.
+        * self._failed_lookups stores concept IDs for which lookups have been attempted
+        and failed. Because we don't associate these IDs with groups, a separate
+        mapping is necessary to prevent repeat queries.
+
+        :param Database database: db instance to use for record retrieval and creation.
         """
-        self._database = database
-        self._groups = {}  # dict keying concept IDs to group Sets
+        self.database = database
+        self._groups: Dict[str, Set[str]] = {}
+        self._unii_to_drugsatfda: Dict[str, Set[str]] = {}
+        self._failed_lookups: Set[str] = set()
 
-    def create_merged_concepts(self, record_ids: Set[str]):
-        """Create concept groups, generate merged concept records, and
-        update database.
+    def create_merged_concepts(self, record_ids: Set[str]) -> None:
+        """Create concept groups, generate merged concept records, and update database.
 
-        :param Set[str] record_ids: concept identifiers from which groups
-            should be generated. Should *not* include any records from
-            excluded sources.
+        :param Set[str] record_ids: concept identifiers from which groups should be
+            generated.
         """
-        logger.info('Generating record ID sets...')
+        logger.info("Generating record ID sets...")
         start = timer()
         for record_id in record_ids:
             new_group = self._create_record_id_set(record_id)
@@ -38,11 +49,12 @@ def create_merged_concepts(self, record_ids: Set[str]):
                 for concept_id in new_group:
                     self._groups[concept_id] = new_group
         end = timer()
-        logger.debug(f'Built record ID sets in {end - start} seconds')
+        logger.debug(f"Built record ID sets in {end - start} seconds")
 
+        # don't create separate records for single-member groups
         self._groups = {k: v for k, v in self._groups.items() if len(v) > 1}
 
-        logger.info('Creating merged records and updating database...')
+        logger.info("Creating merged records and updating database...")
         uploaded_ids = set()
         start = timer()
         for record_id, group in self._groups.items():
@@ -51,153 +63,204 @@ def create_merged_concepts(self, record_ids: Set[str]):
             merged_record = self._generate_merged_record(group)
 
             # add group merger item to DB
-            self._database.add_record(merged_record, 'merger')
+            self.database.add_record(merged_record, "merger")
 
             # add updated references
             for concept_id in group:
-                if not self._database.get_record_by_id(concept_id, False):
+                if not self.database.get_record_by_id(concept_id, False):
                     logger.error(f"Updating nonexistent record: {concept_id} "
                                  f"for {merged_record['label_and_type']}")
                 else:
-                    merge_ref = merged_record['concept_id'].lower()
-                    self._database.update_record(concept_id, 'merge_ref',
-                                                 merge_ref)
+                    merge_ref = merged_record["concept_id"].lower()
+                    self.database.update_record(concept_id, "merge_ref", merge_ref)
             uploaded_ids |= group
-        logger.info('Merged concept generation successful.')
+        logger.info("Merged concept generation successful.")
         end = timer()
-        logger.debug(f'Generated and added concepts in {end - start} seconds')
+        logger.debug(f"Generated and added concepts in {end - start} seconds")
+
+    def _rxnorm_brand_lookup(self, record_id: str) -> Optional[str]:
+        """Rx Norm provides brand references back to original therapy concepts.  This
+        routine checks whether an ID from a concept group requires an additional
+        dereference to obtain the original RxNorm record.
+
+        :param str record_id: RxNorm concept ID to check
+        :return: concept ID for RxNorm record if successful, None otherwise
+        """
+        brand_lookup = self.database.get_records_by_type(record_id,
+                                                         "rx_brand")
+        n = len(brand_lookup)
+        if n == 1:
+            lookup_id = brand_lookup[0]["concept_id"]
+            db_record = self.database.get_record_by_id(lookup_id,
+                                                       False)
+            if db_record:
+                return db_record["concept_id"]
+        elif n > 1:
+            logger.warning(f"Brand lookup for {record_id} had {n} matches")
+        return None
+
+    def _get_drugsatfda_from_unii(self, ref: Dict) -> Optional[str]:
+        """Given an `associated_with` item keying a UNII code to a Drugs@FDA record,
+        verify that the record can be safely added to a concept group.
+        Drugs@FDA tracks a number of "compound therapies", and provides UNIIs to each
+        individual component. If we included them in normalized record sets, they would
+        end up merging distinct therapies under the umbrella of the compound group.
+        We're excluding Drugs@FDA records with multiple UNIIs as a tentative solution.
 
-    def _get_xrefs(self, record: Dict) -> Set[str]:
+        :param Dict ref: `associated_with` item where `label_and_type` includes
+            a UNII code and `src_name` == "DrugsAtFDA"
+        :return: Drugs@FDA concept ID if record meets rules, None otherwise
+        """
+        concept_id = ref["concept_id"]
+        fetched = self.database.get_record_by_id(concept_id, False)
+        if fetched:
+            uniis = [a for a
+                     in fetched.get("associated_with", [])
+                     if a.startswith("unii")]
+        else:
+            logger.error(f"Couldn't retrieve record for {concept_id} from {ref}")
+            return None
+        if len(uniis) == 1:
+            return fetched["concept_id"]
+        else:
+            return None
+
+    def _get_xrefs(self, record: Dict[str, Any]) -> Set[str]:
         """Extract references to entries in other sources from a record.
-        Crucially, filter to allowed sources only.
 
         :param Dict record: record to process
         :return: Set of xref values
-        :rtype: Set
         """
-        xrefs = set()
-        for xref in record.get('xrefs', []):
-            if xref.split(':')[0] not in PROHIBITED_SOURCES:
-                xrefs.add(xref)
+        xrefs = set(record.get("xrefs", []))
+        unii_xrefs = [a for a in record.get("associated_with", [])
+                      if a.startswith("unii:")]
+        for unii in unii_xrefs:
+            # get Drugs@FDA records that are associated_with this UNII
+            drugsatfda_ids = self._unii_to_drugsatfda.get(unii)
+            if drugsatfda_ids is not None:
+                xrefs |= drugsatfda_ids
+                continue
+
+            unii_assoc = self.database.get_records_by_type(unii.lower(),
+                                                           "associated_with")
+            drugsatfda_refs = set()
+            for ref in unii_assoc:
+                if ref["src_name"] == "DrugsAtFDA":
+                    drugsatfda_ref = self._get_drugsatfda_from_unii(ref)
+                    if drugsatfda_ref:
+                        drugsatfda_refs.add(drugsatfda_ref)
+            self._unii_to_drugsatfda[unii] = drugsatfda_refs
+            xrefs |= drugsatfda_refs
         return xrefs
 
     def _create_record_id_set(self, record_id: str,
-                              observed_id_set: Set = set()) -> Set[str]:
+                              observed_id_set: Set = None) -> Set[str]:
         """Create concept ID group for an individual record ID.
 
         :param str record_id: concept ID for record to build group from
         :return: set of related identifiers pertaining to a common concept.
         """
+        if observed_id_set is None:
+            observed_id_set = set()
+
         if record_id in self._groups:
             return self._groups[record_id]
-        else:
-            db_record = self._database.get_record_by_id(record_id)
-            if not db_record:
-                # attempt RxNorm brand lookup
-                brand_lookup = self._database.get_records_by_type(record_id,
-                                                                  'rx_brand')
-                if len(brand_lookup) == 1:
-                    lookup_id = brand_lookup[0]['concept_id']
-                    db_record = self._database.get_record_by_id(lookup_id,
-                                                                False)
-                    if db_record:
-                        record_id = db_record['concept_id']
-                        return self._create_record_id_set(record_id,
-                                                          observed_id_set)
-                    else:
-                        return observed_id_set - {record_id}
+        elif record_id.startswith("drugsatfda"):
+            return {record_id}
+        elif record_id in self._failed_lookups:
+            return observed_id_set - {record_id}
 
-                else:
-                    logger.warning(f"Record ID set creator could not resolve "
-                                   f"lookup for {record_id} in ID set: "
-                                   f"{observed_id_set}")
-                    return observed_id_set - {record_id}
-
-            local_id_set = self._get_xrefs(db_record)
-            if not local_id_set:
-                return observed_id_set | {db_record['concept_id']}
-            merged_id_set = {record_id} | observed_id_set
-            for local_record_id in local_id_set - observed_id_set:
-                merged_id_set |= self._create_record_id_set(local_record_id,
-                                                            merged_id_set)
-            return merged_id_set
+        # get record
+        db_record = self.database.get_record_by_id(record_id)
+        if not db_record:
+            if record_id.startswith("rxcui"):
+                brand_lookup = self._rxnorm_brand_lookup(record_id)
+                if brand_lookup:
+                    return self._create_record_id_set(brand_lookup, observed_id_set)
+            logger.warning(f"Unable to resolve lookup for {record_id} in "
+                           f"ID set: {observed_id_set}")
+            self._failed_lookups.add(record_id)
+            return observed_id_set - {record_id}
+
+        # construct group
+        local_id_set = self._get_xrefs(db_record)
+        if not local_id_set:
+            return observed_id_set | {db_record["concept_id"]}
+        merged_id_set = {record_id} | observed_id_set
+        for local_record_id in local_id_set - observed_id_set:
+            merged_id_set |= self._create_record_id_set(local_record_id, merged_id_set)
+        return merged_id_set
 
     def _generate_merged_record(self, record_id_set: Set[str]) -> Dict:
         """Generate merged record from provided concept ID group.
-        Where attributes are sets, they should be merged, and where they are
-        scalars, assign from the highest-priority source where that attribute
-        is non-null.
+        Where attributes are 'set-like', they should be combined, and where
+        they are 'scalar-like', assign from the highest-priority source where
+        that attribute is not None.
 
-        Priority is:
-        RxNorm > NCIt > HemOnc.org > DrugBank > ChemIDplus > Wikidata
-        ChEMBL records shouldn't be included in `record_id_set`.
+        Uses the SourcePriority schema to define source priority.
 
         :param Set record_id_set: group of concept IDs
         :return: completed merged drug object to be stored in DB
         """
         records = []
         for record_id in record_id_set:
-            record = self._database.get_record_by_id(record_id)
+            record = self.database.get_record_by_id(record_id)
             if record:
                 records.append(record)
             else:
                 logger.error(f"Merge record generator could not retrieve "
                              f"record for {record_id} in {record_id_set}")
 
-        def record_order(record):
+        def record_order(record: Dict) -> Tuple[int, str]:
             """Provide priority values of concepts for sort function."""
-            src = record['src_name'].upper()
+            src = record["src_name"].upper()
+            if src == "DRUGS@FDA":
+                src = "DRUGSATFDA"
             if src in SourcePriority.__members__:
                 source_rank = SourcePriority[src].value
             else:
                 raise Exception(f"Prohibited source: {src} in concept_id "
                                 f"{record['concept_id']}")
-            return (source_rank, record['concept_id'])
+            return source_rank, record["concept_id"]
         records.sort(key=record_order)
 
         # initialize merged record
         merged_attrs = {
-            'concept_id': records[0]['concept_id'],
-            'label': None,
-            'aliases': set(),
-            'trade_names': set(),
-            'associated_with': set(),
-            'approval_status': None,
-            'approval_year': set(),
-            'fda_indication': [],
+            "concept_id": records[0]["concept_id"],
+            "xrefs": [r["concept_id"] for r in records[1:]],
+            "label": None,
+            "aliases": set(),
+            "trade_names": set(),
+            "associated_with": set(),
+            "approval_ratings": set(),
+            "approval_year": set(),
+            "has_indication": [],
         }
-        if len(records) > 1:
-            merged_attrs['xrefs'] = [r['concept_id'] for r in records[1:]]
 
         # merge from constituent records
-        set_fields = ['aliases', 'trade_names', 'associated_with',
-                      'approval_year']
+        set_fields = ["aliases", "trade_names", "associated_with", "approval_year"]
         for record in records:
             for field in set_fields:
                 merged_attrs[field] |= set(record.get(field, set()))
-            chembl_ids = {i for i in record.get('xrefs', [])
-                          if i.startswith('chembl')}
-            merged_attrs['associated_with'] |= chembl_ids
-            if merged_attrs['label'] is None:
-                merged_attrs['label'] = record.get('label')
-            if merged_attrs['approval_status'] is None:
-                merged_attrs['approval_status'] = record.get('approval_status')
-            for ind in record.get('fda_indication', []):
-                if ind not in merged_attrs['fda_indication']:
-                    merged_attrs['fda_indication'].append(ind)
+            approval_rating = record.get("approval_rating")
+            if approval_rating:
+                merged_attrs["approval_ratings"].add(approval_rating)
+            if merged_attrs["label"] is None:
+                merged_attrs["label"] = record.get("label")
+            for ind in record.get("has_indication", []):
+                if ind not in merged_attrs["has_indication"]:
+                    merged_attrs["has_indication"].append(ind)
 
         # clear unused fields
-        for field in set_fields + ['fda_indication']:
+        for field in set_fields + ["has_indication", "approval_ratings"]:
             field_value = merged_attrs[field]
             if field_value:
                 merged_attrs[field] = list(field_value)
             else:
                 del merged_attrs[field]
-        for field in ['label', 'approval_status']:
-            if merged_attrs[field] is None:
-                del merged_attrs[field]
+        if not merged_attrs["label"]:
+            del merged_attrs["label"]
 
-        merged_attrs['label_and_type'] = \
-            f'{merged_attrs["concept_id"].lower()}##merger'
-        merged_attrs['item_type'] = 'merger'
+        merged_attrs["label_and_type"] = f"{merged_attrs['concept_id'].lower()}##merger"
+        merged_attrs["item_type"] = "merger"
         return merged_attrs
diff --git a/therapy/etl/ncit.py b/therapy/etl/ncit.py
index 3d1d11b4..443a654c 100644
--- a/therapy/etl/ncit.py
+++ b/therapy/etl/ncit.py
@@ -1,76 +1,75 @@
 """ETL methods for NCIt source"""
-from .base import Base
-from therapy import PROJECT_ROOT
-from therapy.schemas import SourceName, NamespacePrefix, Therapy, SourceMeta
 import logging
-import owlready2 as owl
-from owlready2.entity import ThingClass
 from typing import Set
-from pathlib import Path
+
 import requests
-import zipfile
-from os import remove, rename
+import owlready2 as owl
+from owlready2.entity import ThingClass
+
+from therapy import DownloadException
+from therapy.schemas import SourceName, NamespacePrefix, SourceMeta, RecordParams
+from therapy.etl.base import Base
 
-logger = logging.getLogger('therapy')
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 
 class NCIt(Base):
-    """Core NCIt ETL class.
+    """Class for NCIt ETL methods.
 
-    Extracting both:
+    Extracts:
      * NCIt classes with semantic_type "Pharmacologic Substance" but not
        Retired_Concept
      * NCIt classes that are subclasses of C1909 (Pharmacologic Substance)
     """
 
-    def __init__(self,
-                 database,
-                 src_dir: str = "https://evs.nci.nih.gov/ftp1/NCI_Thesaurus/archive/2020/20.09d_Release/",  # noqa F401
-                 src_fname: str = "Thesaurus_20.09d.OWL.zip",
-                 data_path: Path = PROJECT_ROOT / 'data',
-                 chemidplus_path: Path = PROJECT_ROOT / 'data' / 'chemidplus'):
-        """Override base class init method. Call ETL methods.
-
-        :param therapy.database.Database database: app database instance
-        :param str src_dir: URL of remote directory containing source input
-        :param str src_fname: filename for source file within source directory
-        :param pathlib.Path data_path: path to local NCIt data directory
+    def _download_data(self) -> None:
+        """Download NCI thesaurus source file.
+        The NCI directory structure can be a little tricky, so this method attempts to
+        retrieve a file matching the latest version number from both the subdirectory
+        root (where the current version is typically posted) as well as the year-by-year
+        archives if that fails.
         """
-        super().__init__(database, data_path)
-        self._SRC_DIR = src_dir
-        self._SRC_FNAME = src_fname
-        self._chemidplus_path = chemidplus_path
-
-    def _download_data(self):
-        """Download NCI thesaurus source file for loading into normalizer."""
-        logger.info('Downloading NCI Thesaurus...')
-        url = self._SRC_DIR + self._SRC_FNAME
-        zip_path = self._src_data_dir / 'ncit.zip'
-        response = requests.get(url, stream=True)
-        handle = open(zip_path, "wb")
-        for chunk in response.iter_content(chunk_size=512):
-            if chunk:
-                handle.write(chunk)
-        handle.close()
-        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
-            zip_ref.extractall(self._src_data_dir)
-        remove(zip_path)
-        version = self._SRC_DIR.split('/')[-2].split('_')[0]
-        rename(self._src_data_dir / 'Thesaurus.owl', self._src_data_dir / f'ncit_{version}.owl')  # noqa: E501
-        logger.info('Finished downloading NCI Thesaurus')
+        logger.info("Retrieving source data for NCIt")
+        base_url = "https://evs.nci.nih.gov/ftp1/NCI_Thesaurus"
+        # ping base NCIt directory
+        release_fname = f"Thesaurus_{self._version}.OWL.zip"
+        src_url = f"{base_url}/{release_fname}"
+        r_try = requests.get(src_url)
+        if r_try.status_code != 200:
+            # ping NCIt archive directories
+            archive_url = f"{base_url}/archive/{self._version}_Release/{release_fname}"
+            archive_try = requests.get(archive_url)
+            if archive_try.status_code != 200:
+                old_archive_url = f"{base_url}/archive/20{self._version[0:2]}/{self._version}_Release/{release_fname}"  # noqa: E501
+                old_archive_try = requests.get(old_archive_url)
+                if old_archive_try.status_code != 200:
+                    msg = (
+                        f"NCIt download failed: tried {src_url}, {archive_url}, and "
+                        f"{old_archive_url}"
+                    )
+                    logger.error(msg)
+                    raise DownloadException(msg)
+                else:
+                    src_url = old_archive_url
+            else:
+                src_url = archive_url
+
+        self._http_download(src_url, self._src_dir / f"ncit_{self._version}.owl",
+                            handler=self._zip_handler)
+        logger.info("Successfully retrieved source data for NCIt")
 
     def _get_desc_nodes(self, node: ThingClass,
                         uq_nodes: Set[ThingClass]) -> Set[ThingClass]:
         """Create set of unique subclasses of node parameter.
         Should be originally called on ncit:C1909: Pharmacologic Substance.
 
-        :param owlready2.entity.ThingClass node: concept node to either
-            retrieve descendants of, or to normalize and add to DB
-        :param Set[owlready2.entity.ThingClass] uq_nodes: set of unique class
-            nodes found so far from recursive tree exploration
-        :return: the uq_nodes set, updated with any class nodes found from
-            recursive exploration of this branch of the class tree
+        :param owlready2.entity.ThingClass node: concept node to either retrieve
+            descendants of, or to normalize and add to DB
+        :param Set[owlready2.entity.ThingClass] uq_nodes: set of unique class nodes
+            found so far from recursive tree exploration
+        :return: the uq_nodes set, updated with any class nodes found from recursive
+            exploration of this branch of the class tree
         :rtype: Set[owlready2.entity.ThingClass]
         """
         children = node.descendants()
@@ -85,39 +84,39 @@ def _get_typed_nodes(self, uq_nodes: Set[ThingClass],
                          ncit: owl.namespace.Ontology) -> Set[ThingClass]:
         """Get all nodes with semantic_type Pharmacologic Substance
 
-        :param Set[owlready2.entity.ThingClass] uq_nodes: set of unique class
-            nodes found so far.
-        :param owl.namespace.Ontology ncit: owlready2 Ontology instance for
-            NCI Thesaurus.
-        :return: uq_nodes, with the addition of all classes found to have
-            semantic_type Pharmacologic Substance and not of type
-            Retired_Concept
+        :param Set[owlready2.entity.ThingClass] uq_nodes: set of unique class nodes
+            found so far.
+        :param owl.namespace.Ontology ncit: owlready2 Ontology instance for NCIt.
+        :return: uq_nodes, with the addition of all classes found to have semantic_type
+            Pharmacologic Substance and not of type Retired_Concept
         :rtype: Set[owlready2.entity.ThingClass]
         """
         graph = owl.default_world.as_rdflib_graph()
 
-        query_str = '''SELECT ?x WHERE {
+        query_str = """SELECT ?x WHERE {
             ?x <http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P106>
-            "Pharmacologic Substance"
-        }'''
+            \"Pharmacologic Substance\"
+        }
+        """
         typed_results = set(graph.query(query_str))
 
-        retired_query_str = '''SELECT ?x WHERE {
+        retired_query_str = """SELECT ?x WHERE {
             ?x <http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#P310>
-            "Retired_Concept"
+            \"Retired_Concept\"
         }
-        '''
+        """
         retired_results = set(graph.query(retired_query_str))
 
-        typed_results = typed_results - retired_results
+        typed_results = {r for r in (typed_results - retired_results)
+                         if r is not None}
 
         for result in typed_results:
             # parse result as URI and get ThingClass object back from NCIt
-            class_object = ncit[result[0].toPython().split('#')[1]]
+            class_object = ncit[result[0].toPython().split("#")[1]]
             uq_nodes.add(class_object)
         return uq_nodes
 
-    def _transform_data(self):
+    def _transform_data(self) -> None:
         """Get data from file and construct objects for loading"""
         ncit = owl.get_ontology(self._src_file.absolute().as_uri())
         ncit.load()
@@ -130,7 +129,7 @@ def _transform_data(self):
                 label = node.P108.first()
             else:
                 label = None
-            aliases = node.P90
+            aliases = node.P90.copy()  # prevent CallbackList error
             if label and aliases and label in aliases:
                 aliases.remove(label)
 
@@ -143,36 +142,39 @@ def _transform_data(self):
                 xrefs.append(f"{NamespacePrefix.CASREGISTRY.value}:"
                              f"{node.P210.first()}")
             if node.P319:
-                associated_with.append(f"{NamespacePrefix.FDA.value}:"
+                associated_with.append(f"{NamespacePrefix.UNII.value}:"
                                        f"{node.P319.first()}")
             if node.P320:
                 associated_with.append(f"{NamespacePrefix.ISO.value}:"
                                        f"{node.P320.first()}")
             if node.P368:
-                associated_with.append(f"{NamespacePrefix.CHEBI.value}:"
-                                       f"{node.P368.first()}")
-            params = {
-                'concept_id': concept_id,
-                'label': label,
-                'aliases': aliases,
-                'xrefs': xrefs,
-                'associated_with': associated_with
+                iri = node.P368.first()
+                if ":" in iri:
+                    iri = iri.split(":")[1]
+                associated_with.append(f"{NamespacePrefix.CHEBI.value}:{iri}")
+            params: RecordParams = {  # type: ignore
+                "concept_id": concept_id,
+                "label": label,
+                "aliases": aliases,
+                "xrefs": xrefs,
+                "associated_with": associated_with
             }
-            assert Therapy(**params)
             self._load_therapy(params)
 
-    def _load_meta(self):
+    def _load_meta(self) -> None:
         """Load metadata"""
-        metadata = SourceMeta(data_license="CC BY 4.0",
-                              data_license_url="https://creativecommons.org/licenses/by/4.0/legalcode",  # noqa F401
-                              version=self._version,
-                              data_url=self._SRC_DIR,
-                              rdp_url='http://reusabledata.org/ncit.html',
-                              data_license_attributes={
-                                  'non_commercial': False,
-                                  'share_alike': False,
-                                  'attribution': True
-                              })
+        metadata = SourceMeta(
+            data_license="CC BY 4.0",
+            data_license_url="https://creativecommons.org/licenses/by/4.0/legalcode",  # noqa F401
+            version=self._version,
+            data_url="https://evs.nci.nih.gov/ftp1/NCI_Thesaurus/",
+            rdp_url="http://reusabledata.org/ncit.html",
+            data_license_attributes={
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": True
+            }
+        )
         params = dict(metadata)
-        params['src_name'] = SourceName.NCIT.value
+        params["src_name"] = SourceName.NCIT.value
         self.database.metadata.put_item(Item=params)
diff --git a/therapy/etl/rxnorm.py b/therapy/etl/rxnorm.py
index 3287bf89..fb69a47d 100644
--- a/therapy/etl/rxnorm.py
+++ b/therapy/etl/rxnorm.py
@@ -5,224 +5,192 @@
  and Human Services; NLM is not responsible for the product and does not
  endorse or recommend this or any other product."
 """
-from .base import Base
-from therapy import PROJECT_ROOT, DownloadException, XREF_SOURCES, \
-    ASSOC_WITH_SOURCES, ITEM_TYPES
-import therapy
-from therapy.database import Database
-from therapy.schemas import SourceName, NamespacePrefix, SourceMeta, Drug, \
-    ApprovalStatus
 import csv
-import datetime
 import logging
-from os import environ, remove
-import subprocess
 import shutil
 import zipfile
 import re
-import yaml
+from os import environ, remove
+from typing import List, Dict
 from pathlib import Path
 
-logger = logging.getLogger('therapy')
+import yaml
+import bioversions
+from boto3.dynamodb.table import BatchWriter
+import requests
+
+from therapy import DownloadException, XREF_SOURCES, ASSOC_WITH_SOURCES, ITEM_TYPES
+from therapy.schemas import SourceName, NamespacePrefix, SourceMeta, Drug, \
+    ApprovalRating, RecordParams
+from therapy.etl.base import Base
+
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
 # Designated Alias, Designated Syn, Tall Man Syn, Machine permutation
 # Generic Drug Name, Designated Preferred Name, Preferred Entry Term,
 # Clinical Drug, Entry Term, Rxnorm Preferred
-ALIASES = ['SYN', 'SY', 'TMSY', 'PM',
-           'GN', 'PT', 'PEP', 'CD', 'ET', 'RXN_PT']
+ALIASES = ["SYN", "SY", "TMSY", "PM", "GN", "PT", "PEP", "CD", "ET", "RXN_PT"]
 
 # Fully-specified drug brand name that can be prescribed
 # Fully-specified drug brand name that can not be prescribed,
 # Semantic branded drug
-TRADE_NAMES = ['BD', 'BN', 'SBD']
+TRADE_NAMES = ["BD", "BN", "SBD"]
 
 # Allowed rxnorm xrefs that have Source Level Restriction 0 or 1
-RXNORM_XREFS = ['ATC', 'CVX', 'DRUGBANK', 'MMSL', 'MSH', 'MTHCMSFRF', 'MTHSPL',
-                'RXNORM', 'USP', 'VANDF']
+RXNORM_XREFS = ["ATC", "CVX", "DRUGBANK", "MMSL", "MSH", "MTHCMSFRF", "MTHSPL",
+                "RXNORM", "USP", "VANDF"]
 
 
 class RxNorm(Base):
-    """Extract, transform, and load the RxNorm source."""
-
-    def __init__(self,
-                 database: Database,
-                 data_path=PROJECT_ROOT / 'data',
-                 data_url='https://www.nlm.nih.gov/research/umls/'
-                          'rxnorm/docs/rxnormfiles.html'):
-        """Initialize the RxNorm ETL class.
-
-        :param Database database: Access to DynamoDB.
-        :param Path data_path: path to app data directory
-        :param str data_url: URL to RxNorm data files.
+    """Class for RxNorm ETL methods."""
+
+    def _create_drug_form_yaml(self) -> None:
+        """Create a YAML file containing RxNorm drug form values."""
+        self._drug_forms_file = self._src_dir / f"rxnorm_drug_forms_{self._version}.yaml"  # noqa: E501
+        dfs = []
+        with open(self._src_file) as f:  # type: ignore
+            data = csv.reader(f, delimiter="|")
+            for row in data:
+                if row[12] == "DF" and row[11] == "RXNORM":
+                    if row[14] not in dfs:
+                        dfs.append(row[14])
+        with open(self._drug_forms_file, "w") as file:
+            yaml.dump(dfs, file)
+
+    def _zip_handler(self, dl_path: Path, outfile_path: Path) -> None:
+        """Extract required files from RxNorm zip. This method should be passed to
+        the base class's _http_download method.
+        :param Path dl_path: path to RxNorm zip file in tmp directory
+        :param Path outfile_path: path to RxNorm data directory
         """
-        super().__init__(database, data_path)
-        self._data_url = data_url
-
-    def _extract_data(self, *args, **kwargs):
-        """Extract data from the RxNorm source."""
-        logger.info('Extracting RxNorm...')
-        if 'data_path' in kwargs:
-            self._data_src = kwargs['data_path']
-        else:
-            rxn_dir = PROJECT_ROOT / 'data' / 'rxnorm'
-            rxn_dir.mkdir(exist_ok=True, parents=True)
-            rxn_files = list(rxn_dir.iterdir())
-            if len(rxn_files) == 0:
-                self._download_data(rxn_dir)
-
-            # file might already exist
-            files = sorted([fn for fn in rxn_dir.iterdir() if fn.name.
-                           startswith('rxnorm_') and fn.name.endswith('.RRF')],
-                           reverse=True)
-            file_found = False
-            for file in files:
-                version = str(file).split('_')[-1].split('.')[0]
-                try:
-                    datetime.datetime.strptime(version, '%Y%m%d')
-                    self._data_src = file
-                    self._version = version
-                    file_found = True
-                    break
-                except ValueError:
-                    pass
-            if not file_found:
-                self._download_data(rxn_dir)
-            self._create_drug_form_yaml(rxn_dir)
-        logger.info('Successfully extracted RxNorm.')
-
-    def _download_data(self, rxn_dir):
-        """Download RxNorm data file.
-
-        :param Path rxn_dir: Path to RxNorm data directory.
+        rrf_path = outfile_path / f"rxnorm_{self._version}.RRF"
+        with zipfile.ZipFile(dl_path, "r") as zf:
+            rrf = zf.open("rrf/RXNCONSO.RRF")
+            target = open(rrf_path, "wb")
+            with rrf, target:
+                shutil.copyfileobj(rrf, target)
+        remove(dl_path)
+        self._src_file = rrf_path
+        self._create_drug_form_yaml()
+        logger.info("Successfully retrieved source data for RxNorm")
+
+    def _download_data(self) -> None:
+        """Download latest RxNorm data file.
+
+        :raises DownloadException: if API Key is not defined in the environment.
         """
-        logger.info('Downloading RxNorm...')
-        if 'RXNORM_API_KEY' in environ.keys():
-            uri = 'https://download.nlm.nih.gov/umls/kss/' \
-                  'rxnorm/RxNorm_full_current.zip'
-
-            rxnorm_path = str(Path(therapy.__file__).resolve().parents[0] / 'data' / 'rxnorm' / 'RxNorm_full_current.zip')  # noqa: E501
-            environ['RXNORM_PATH'] = rxnorm_path
-
-            # Source:
-            # https://documentation.uts.nlm.nih.gov/automating-downloads.html
-            subprocess.call(['bash', f'{PROJECT_ROOT}/etl/'
-                                     f'rxnorm_download.sh', uri])
-
-            with zipfile.ZipFile(rxnorm_path, 'r') as zf:
-                zf.extractall(rxn_dir)
-
-            remove(rxnorm_path)
-            shutil.rmtree(rxn_dir / 'prescribe')
-            shutil.rmtree(rxn_dir / 'scripts')
-
-            readme = sorted([fn for fn in rxn_dir.iterdir() if fn.name.
-                            startswith('Readme')])[0]
-
-            # get version
-            version = str(readme).split('/')[-1].split('.')[0].split('_')[-1]
-
-            self._version = datetime.datetime.strptime(
-                version, '%m%d%Y').strftime('%Y%m%d')
-            remove(readme)
-
-            temp_file = rxn_dir / 'rrf' / 'RXNCONSO.RRF'
-            self._data_src = rxn_dir / f"rxnorm_{self._version}.RRF"
-            shutil.move(temp_file, self._data_src)
-            shutil.rmtree(rxn_dir / 'rrf')
-        else:
-            logger.error('Could not find RXNORM_API_KEY in environment '
-                         'variables.')
+        logger.info("Retrieving source data for RxNorm")
+        api_key = environ.get("RXNORM_API_KEY")
+        if api_key is None:
+            logger.error("Could not find RXNORM_API_KEY in environment variables.")
             raise DownloadException("RXNORM_API_KEY not found.")
 
-    def _create_drug_form_yaml(self, rxn_dir):
-        """Create a YAML file containing RxNorm drug form values.
-
-        :param Path rxn_dir: Path to RxNorm data directory.
+        url = bioversions.resolve("rxnorm").homepage
+        if not url:
+            raise DownloadException("Could not resolve RxNorm homepage")
+
+        tgt_data = {"apikey": api_key}
+        headers = {"Content-Type": "application/x-www-form-urlencoded"}
+        api_url = "https://utslogin.nlm.nih.gov/cas/v1/api-key"
+        tgt_r = requests.post(api_url,
+                              data=tgt_data, headers=headers)
+        tgt_matches = re.findall(r'https://.+(TGT.+)" m', tgt_r.text)
+        if not tgt_matches:
+            raise DownloadException("Unable to retrieve TGT")
+        tgt_value = tgt_matches[0]
+
+        st_data = {"service": url}
+        st_url = f"https://utslogin.nlm.nih.gov/cas/v1/tickets/{tgt_value}"
+        st_r = requests.post(st_url, data=st_data, headers=headers)
+
+        self._http_download(f"{url}?ticket={st_r.text}", self._src_dir,
+                            handler=self._zip_handler)
+
+    def _extract_data(self) -> None:
+        """Get source files from RxNorm data directory.
+        This class expects a file named `rxnorm_<version>.RRF` and a file named
+        `rxnorm_drug_forms_<version>.yaml`. This method will download and
+        generate them if they are unavailable.
         """
-        self._drug_forms_file = rxn_dir / 'drug_forms.yaml'
-        if not self._drug_forms_file.exists():
-            dfs = list()
-            with open(self._data_src) as f:
-                data = csv.reader(f, delimiter='|')
-                for row in data:
-                    if row[12] == 'DF' and row[11] == 'RXNORM':
-                        if row[14] not in dfs:
-                            dfs.append(row[14])
-
-            with open(self._drug_forms_file, 'w') as file:
-                yaml.dump(dfs, file)
-
-    def _transform_data(self):
+        super()._extract_data()
+        drug_forms_path = self._src_dir / f"rxnorm_drug_forms_{self._version}.yaml"
+        if not drug_forms_path.exists():
+            self._create_drug_form_yaml()
+        else:
+            self._drug_forms_file = drug_forms_path
+
+    def _transform_data(self) -> None:
         """Transform the RxNorm source."""
-        with open(self._drug_forms_file, 'r') as file:
+        with open(self._drug_forms_file, "r") as file:
             drug_forms = yaml.safe_load(file)
 
-        with open(self._data_src) as f:
-            rff_data = csv.reader(f, delimiter='|')
-            ingredient_brands = dict()  # Link ingredient to brand
-            precise_ingredient = dict()  # Link precise ingredient to get brand
-            data = dict()  # Transformed therapy records
-            sbdfs = dict()  # Link ingredient to brand
-            brands = dict()  # Get RXNORM|BN to concept_id
+        with open(self._src_file) as f:
+            rff_data = csv.reader(f, delimiter="|")
+            # Link ingredient to brand
+            ingredient_brands: Dict[str, str] = dict()
+            # Link precise ingredient to get brand
+            precise_ingredient: Dict[str, str] = dict()
+            # Transformed therapy records
+            data: Dict[str, Dict] = dict()
+            # Link ingredient to brand
+            sbdfs: Dict[str, str] = dict()
+            # Get RXNORM|BN to concept_id
+            brands: Dict[str, str] = dict()
             for row in rff_data:
                 if row[11] in RXNORM_XREFS:
                     concept_id = f"{NamespacePrefix.RXNORM.value}:{row[0]}"
-                    if row[12] == 'BN' and row[11] == 'RXNORM':
+                    if row[12] == "BN" and row[11] == "RXNORM":
                         brands[row[14]] = concept_id
-                    if row[12] == 'SBDC' and row[11] == 'RXNORM':
+                    if row[12] == "SBDC" and row[11] == "RXNORM":
                         # Semantic Branded Drug Component
                         self._get_brands(row, ingredient_brands)
                     else:
                         if concept_id not in data.keys():
-                            params = dict()
-                            params['concept_id'] = concept_id
-                            self._add_str_field(params, row,
-                                                precise_ingredient,
+                            params: RecordParams = {}
+                            params["concept_id"] = concept_id
+                            self._add_str_field(params, row, precise_ingredient,
                                                 drug_forms, sbdfs)
                             self._add_xref_assoc(params, row)
                             data[concept_id] = params
                         else:
                             # Concept already created
                             params = data[concept_id]
-                            self._add_str_field(params, row,
-                                                precise_ingredient,
+                            self._add_str_field(params, row, precise_ingredient,
                                                 drug_forms, sbdfs)
                             self._add_xref_assoc(params, row)
 
             with self.database.therapies.batch_writer() as batch:
-                for key, value in data.items():
-                    if 'label' in value:
+                for value in data.values():
+                    if "label" in value:
                         self._get_trade_names(value, precise_ingredient,
                                               ingredient_brands, sbdfs)
                         self._load_brand_concepts(value, brands, batch)
 
-                        params = {
-                            'concept_id': value['concept_id']
-                        }
+                        params = {"concept_id": value["concept_id"]}
 
-                        for field in list(ITEM_TYPES.keys()) + ['approval_status']:  # noqa: E501
+                        for field in list(ITEM_TYPES.keys()) + ["approval_rating"]:
                             field_value = value.get(field)
                             if field_value:
                                 params[field] = field_value
                         assert Drug(**params)
                         self._load_therapy(params)
 
-    def _get_brands(self, row, ingredient_brands):
+    def _get_brands(self, row: List, ingredient_brands: Dict) -> None:
         """Add ingredient and brand to ingredient_brands.
 
-        :param list row: A row in the RxNorm data file.
-        :param dict ingredient_brands: Store brands for each ingredient
+        :param List row: A row in the RxNorm data file.
+        :param Dict ingredient_brands: Store brands for each ingredient
         """
         # SBDC: Ingredient(s) + Strength + [Brand Name]
         term = row[14]
         ingredients_brand = \
             re.sub(r"(\d*)(\d*\.)?\d+ (MG|UNT|ML)?(/(ML|HR|MG))?",
                    "", term)
-        brand = term.split('[')[-1].split(']')[0]
-        ingredients = ingredients_brand.replace(f"[{brand}]", '')
-        if '/' in ingredients:
-            ingredients = ingredients.split('/')
+        brand = term.split("[")[-1].split("]")[0]
+        ingredients = ingredients_brand.replace(f"[{brand}]", "")
+        if "/" in ingredients:
+            ingredients = ingredients.split("/")
             for ingredient in ingredients:
                 self._add_term(ingredient_brands, brand,
                                ingredient.strip())
@@ -230,103 +198,103 @@ def _get_brands(self, row, ingredient_brands):
             self._add_term(ingredient_brands, brand,
                            ingredients.strip())
 
-    def _get_trade_names(self, value, precise_ingredient, ingredient_brands,
-                         sbdfs):
+    def _get_trade_names(self, value: Dict, precise_ingredient: Dict,
+                         ingredient_brands: Dict, sbdfs: Dict) -> None:
         """Get trade names for a given ingredient.
 
-        :param dict value: Therapy attributes
-        :param dict precise_ingredient: Brand names for precise ingredient
-        :param dict ingredient_brands: Brand names for ingredient
-        :param dict sbdfs: Brand names for ingredient from SBDF row
+        :param Dict value: Therapy attributes
+        :param Dict precise_ingredient: Brand names for precise ingredient
+        :param Dict ingredient_brands: Brand names for ingredient
+        :param Dict sbdfs: Brand names for ingredient from SBDF row
         """
-        record_label = value['label'].lower()
+        record_label = value["label"].lower()
         labels = [record_label]
 
-        if 'PIN' in value and value['PIN'] \
+        if "PIN" in value and value["PIN"] \
                 in precise_ingredient:
-            for pin in precise_ingredient[value['PIN']]:
+            for pin in precise_ingredient[value["PIN"]]:
                 labels.append(pin.lower())
 
         for label in labels:
-            trade_names = \
+            trade_names: List[str] = \
                 [val for key, val in ingredient_brands.items()
                  if label == key.lower()]
-            trade_names = {val for sublist in trade_names
-                           for val in sublist}
-            for tn in trade_names:
-                self._add_term(value, tn, 'trade_names')
+            trade_names_uq = {val for sublist in trade_names
+                              for val in sublist}
+            for tn in trade_names_uq:
+                self._add_term(value, tn, "trade_names")
 
         if record_label in sbdfs:
             for tn in sbdfs[record_label]:
-                self._add_term(value, tn, 'trade_names')
+                self._add_term(value, tn, "trade_names")
 
     @staticmethod
-    def _load_brand_concepts(value, brands, batch):
+    def _load_brand_concepts(value: Dict, brands: Dict, batch: BatchWriter) -> None:
         """Connect brand names to a concept and load into the database.
 
         :params dict value: A transformed therapy record
         :params dict brands: Connects brand names to concept records
         :param BatchWriter batch: Object to write data to DynamoDB.
         """
-        if 'trade_names' in value:
-            for tn in value['trade_names']:
+        if "trade_names" in value:
+            for tn in value["trade_names"]:
                 if brands.get(tn):
                     batch.put_item(Item={
-                        'label_and_type':
+                        "label_and_type":
                             f"{brands.get(tn)}##rx_brand",
-                        'concept_id': value['concept_id'],
-                        'src_name': SourceName.RXNORM.value,
-                        'item_type': 'rx_brand'
+                        "concept_id": value["concept_id"],
+                        "src_name": SourceName.RXNORM.value,
+                        "item_type": "rx_brand"
                     })
 
-    def _add_str_field(self, params, row, precise_ingredient, drug_forms,
-                       sbdfs):
+    def _add_str_field(self, params: Dict, row: List, precise_ingredient: Dict,
+                       drug_forms: List, sbdfs: Dict) -> None:
         """Differentiate STR field.
 
-        :param dict params: A transformed therapy record.
-        :param list row: A row in the RxNorm data file.
-        :param dict precise_ingredient: Precise ingredient information
-        :param list drug_forms: RxNorm Drug Form values
-        :param dict sbdfs: Brand names for precise ingredient
+        :param Dict params: A transformed therapy record.
+        :param List row: A row in the RxNorm data file.
+        :param Dict precise_ingredient: Precise ingredient information
+        :param List drug_forms: RxNorm Drug Form values
+        :param Dict sbdfs: Brand names for precise ingredient
         """
         term = row[14]
         term_type = row[12]
         source = row[11]
 
-        if (term_type == 'IN' or term_type == 'PIN') and source == 'RXNORM':
-            params['label'] = term
-            if row[17] == '4096':
-                params['approval_status'] = ApprovalStatus.APPROVED.value
+        if (term_type == "IN" or term_type == "PIN") and source == "RXNORM":
+            params["label"] = term
+            if row[17] == "4096":
+                params["approval_rating"] = ApprovalRating.RXNORM_PRESCRIBABLE.value
         elif term_type in ALIASES:
-            self._add_term(params, term, 'aliases')
+            self._add_term(params, term, "aliases")
         elif term_type in TRADE_NAMES:
-            self._add_term(params, term, 'trade_names')
+            self._add_term(params, term, "trade_names")
 
-        if source == 'RXNORM':
-            if term_type == 'SBDF':
-                brand = term.split('[')[-1].split(']')[0]
-                ingredient_strength = term.replace(f"[{brand}]", '')
+        if source == "RXNORM":
+            if term_type == "SBDF":
+                brand = term.split("[")[-1].split("]")[0]
+                ingredient_strength = term.replace(f"[{brand}]", "")
                 for df in drug_forms:
                     if df in ingredient_strength:
                         ingredient = \
-                            ingredient_strength.replace(df, '').strip()
+                            ingredient_strength.replace(df, "").strip()
                         self._add_term(sbdfs, brand, ingredient.lower())
                         break
 
-        if source == 'MSH':
-            if term_type == 'MH':
+        if source == "MSH":
+            if term_type == "MH":
                 # Get ID for accessing precise ingredient
-                params['PIN'] = row[13]
-            elif term_type == 'PEP':
+                params["PIN"] = row[13]
+            elif term_type == "PEP":
                 self._add_term(precise_ingredient, term, row[13])
 
     @staticmethod
-    def _add_term(params, term, label_type):
+    def _add_term(params: Dict, term: str, label_type: str) -> None:
         """Add a single term to a therapy record in an associated field.
 
-        :param dict params: A transformed therapy record.
-        :param str term: The term to be added
-        :param str label_type: The type of term
+        :param Dict params: A transformed therapy record.
+        :param Str term: The term to be added
+        :param Str label_type: The type of term
         """
         if label_type in params and params[label_type]:
             if term not in params[label_type]:
@@ -334,40 +302,45 @@ def _add_term(params, term, label_type):
         else:
             params[label_type] = [term]
 
-    def _add_xref_assoc(self, params, row):
+    def _add_xref_assoc(self, params: Dict, row: List) -> None:
         """Add xref or associated_with to therapy.
 
-        :param dict params: A transformed therapy record.
-        :param list row: A row in the RxNorm data file.
+        :param Dict params: A transformed therapy record.
+        :param List row: A row in the RxNorm data file.
         """
-        if row[11]:
-            xref_assoc = row[11].upper()
+        ref = row[11]
+        if ref:
+            if ref == "MTHSPL":
+                xref_assoc = "UNII"
+            else:
+                xref_assoc = row[11].upper()
+
             if xref_assoc in XREF_SOURCES:
                 source_id =\
                     f"{NamespacePrefix[xref_assoc].value}:{row[13]}"
-                if source_id != params['concept_id']:
+                if source_id != params["concept_id"]:
                     # Sometimes concept_id is included in the source field
-                    self._add_term(params, source_id, 'xrefs')
+                    self._add_term(params, source_id, "xrefs")
             elif xref_assoc in ASSOC_WITH_SOURCES:
                 source_id = f"{NamespacePrefix[xref_assoc].value}:{row[13]}"
-                self._add_term(params, source_id, 'associated_with')
+                self._add_term(params, source_id, "associated_with")
             else:
                 logger.info(f"{xref_assoc} not in NameSpacePrefix.")
 
-    def _load_meta(self):
+    def _load_meta(self) -> None:
         """Add RxNorm metadata."""
         meta = SourceMeta(
-            data_license='UMLS Metathesaurus',
-            data_license_url='https://www.nlm.nih.gov/research/umls/rxnorm/docs/termsofservice.html',  # noqa: E501
+            data_license="UMLS Metathesaurus",
+            data_license_url="https://www.nlm.nih.gov/research/umls/rxnorm/docs/termsofservice.html",  # noqa: E501
             version=self._version,
-            data_url=self._data_url,
+            data_url=bioversions.resolve("rxnorm").homepage,
             rdp_url=None,
             data_license_attributes={
-                'non_commercial': False,
-                'share_alike': False,
-                'attribution': True
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": True
             }
         )
         params = dict(meta)
-        params['src_name'] = SourceName.RXNORM.value
+        params["src_name"] = SourceName.RXNORM.value
         self.database.metadata.put_item(Item=params)
diff --git a/therapy/etl/rxnorm_download.sh b/therapy/etl/rxnorm_download.sh
deleted file mode 100644
index 2d87e0f6..00000000
--- a/therapy/etl/rxnorm_download.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/bash
-
-# Source: https://documentation.uts.nlm.nih.gov/automating-downloads.html
-#1. Add your API key from https://uts.nlm.nih.gov/uts/profile
-#2. Run the script (for example: bash curl-uts-downloads-apikey.sh https://download.nlm.nih.gov/umls/kss/rxnorm/RxNorm_weekly_current.zip)
-
-export apikey=$RXNORM_API_KEY
-export DOWNLOAD_URL=$1
-
-export CAS_LOGIN_URL=https://utslogin.nlm.nih.gov/cas/v1/api-key
-
-if [ -z "$apikey" ]; then echo " Please enter you api key "
-   exit
-fi
-
-if [ $# -eq 0 ]; then echo "Usage: curl-uts-downloads-apikey.sh  download_url "
-                      echo "  e.g.   curl-uts-download-apikey.sh https://download.nlm.nih.gov/umls/kss/rxnorm/RxNorm_full_current.zip"
-                      echo "         curl-uts-download-apikey.sh https://download.nlm.nih.gov/umls/kss/rxnorm/RxNorm_weekly_current.zip"
-   exit
-fi
-
-TGT=$(curl -d "apikey="$apikey -H "Content-Type: application/x-www-form-urlencoded" -X POST https://utslogin.nlm.nih.gov/cas/v1/api-key)
-
-TGTTICKET=$(echo $TGT | tr "=" "\n")
-
-for TICKET in $TGTTICKET
-do
-    if [[ "$TICKET" == *"TGT"* ]]; then
-	  SUBSTRING=$(echo $TICKET| cut -d'/' -f 7)
-	  TGTVALUE=$(echo $SUBSTRING | sed 's/.$//')
-	fi
-done
-echo $TGTVALUE
-STTICKET=$(curl -d "service="$DOWNLOAD_URL -H "Content-Type: application/x-www-form-urlencoded" -X POST https://utslogin.nlm.nih.gov/cas/v1/tickets/$TGTVALUE)
-echo $STTICKET
-
-curl -c cookie.txt -b cookie.txt -L -o $RXNORM_PATH -J $DOWNLOAD_URL?ticket=$STTICKET
-rm cookie.txt
\ No newline at end of file
diff --git a/therapy/etl/wikidata.py b/therapy/etl/wikidata.py
index fc6890f4..3a0a9e41 100644
--- a/therapy/etl/wikidata.py
+++ b/therapy/etl/wikidata.py
@@ -1,29 +1,37 @@
 """This module defines the Wikidata ETL methods."""
-from .base import Base
-from therapy import PROJECT_ROOT
-from therapy.schemas import SourceName, NamespacePrefix, \
-    SourceIDAfterNamespace, SourceMeta
 import json
 import logging
-from pathlib import Path
-from wikibaseintegrator.wbi_functions import execute_sparql_query
 import datetime
+from typing import Dict, Any
+
+from wikibaseintegrator.wbi_functions import execute_sparql_query
+
+from therapy import XREF_SOURCES, DownloadException
+from therapy.schemas import SourceName, NamespacePrefix, RecordParams, SourceMeta
+from therapy.etl.base import Base
 
-logger = logging.getLogger('therapy')
+logger = logging.getLogger("therapy")
 logger.setLevel(logging.DEBUG)
 
-# Prefixes for translating ID namespaces
-IDENTIFIER_PREFIXES = {
-    'casRegistry': NamespacePrefix.CASREGISTRY.value,
-    'ChemIDplus': NamespacePrefix.CHEMIDPLUS.value,
-    'pubchemCompound': NamespacePrefix.PUBCHEMCOMPOUND.value,
-    'pubchemSubstance': NamespacePrefix.PUBCHEMSUBSTANCE.value,
-    'chembl': NamespacePrefix.CHEMBL.value,
-    'rxnorm': NamespacePrefix.RXNORM.value,
-    'drugbank': NamespacePrefix.DRUGBANK.value,
-    'wikidata': NamespacePrefix.WIKIDATA.value,
+# Translate Wikidata keys to standardized namespaces
+NAMESPACES = {
+    "casRegistry": NamespacePrefix.CASREGISTRY.value,
+    "ChemIDplus": NamespacePrefix.CHEMIDPLUS.value,
+    "pubchemCompound": NamespacePrefix.PUBCHEMCOMPOUND.value,
+    "pubchemSubstance": NamespacePrefix.PUBCHEMSUBSTANCE.value,
+    "chembl": NamespacePrefix.CHEMBL.value,
+    "rxnorm": NamespacePrefix.RXNORM.value,
+    "drugbank": NamespacePrefix.DRUGBANK.value,
+    "wikidata": NamespacePrefix.WIKIDATA.value,
 }
 
+# Provide standard concept ID prefixes
+ID_PREFIXES = {
+    "wikidata": "Q",
+    "chembl": "CHEMBL",
+    "drugbank": "DB",
+    "ncit": "C",
+}
 
 SPARQL_QUERY = """
     SELECT ?item ?itemLabel ?casRegistry ?pubchemCompound
@@ -32,7 +40,7 @@
       ?item (wdt:P31/(wdt:P279*)) wd:Q12140.
       OPTIONAL {
         ?item skos:altLabel ?alias.
-        FILTER((LANG(?alias)) = "en")
+        FILTER((LANG(?alias)) = \"en\")
       }
       OPTIONAL { ?item p:P231 ?wds1.
                  ?wds1 ps:P231 ?casRegistry.
@@ -53,111 +61,102 @@
                  ?wds6 ps:P715 ?drugbank
                }
       SERVICE wikibase:label {
-        bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en".
+        bd:serviceParam wikibase:language \"[AUTO_LANGUAGE],en\".
       }
     }
 """
 
 
 class Wikidata(Base):
-    """Extract, transform, and load the Wikidata source into therapy.db."""
-
-    def __init__(self,
-                 database,
-                 data_path: Path = PROJECT_ROOT / 'data'):
-        """Initialize wikidata ETL class.
-
-        :param therapy.database.Database: DB instance to use
-        :param Path data_path: path to app data directory
-        """
-        super().__init__(database, data_path)
-
-    def _extract_data(self):
-        """Extract data from the Wikidata source."""
-        self._src_data_dir.mkdir(exist_ok=True, parents=True)
-
-        data = execute_sparql_query(SPARQL_QUERY)['results']['bindings']
-
-        transformed_data = list()
+    """Class for Wikidata ETL methods."""
+
+    def _download_data(self) -> None:
+        """Download latest Wikidata source dump."""
+        logger.info("Retrieving source data for Wikidata")
+        query_results = execute_sparql_query(SPARQL_QUERY)
+        if query_results is None:
+            raise DownloadException("Wikidata SPARQL query returned no results")
+        else:
+            data = query_results["results"]["bindings"]
+
+        transformed_data = []
         for item in data:
-            params = dict()
+            params: RecordParams = {}
             for attr in item:
-                params[attr] = item[attr]['value']
+                params[attr] = item[attr]["value"]
             transformed_data.append(params)
-
-        self._version = datetime.datetime.today().strftime('%Y%m%d')
-        with open(f"{self._src_data_dir}/wikidata_{self._version}.json",
-                  'w+') as f:
+        with open(f"{self._src_dir}/wikidata_{self._version}.json", "w+") as f:
             json.dump(transformed_data, f)
-        self._data_src = sorted(list(self._src_data_dir.iterdir()))[-1]
-        logger.info('Successfully extracted Wikidata.')
+        logger.info("Successfully retrieved source data for Wikidata")
+
+    def get_latest_version(self) -> str:
+        """Wikidata is constantly, immediately updated, so source data has no strict
+        versioning. We use the current date as a pragmatic way to indicate the version.
+        """
+        return datetime.datetime.today().strftime("%Y-%m-%d")
 
-    def _load_meta(self):
+    def _load_meta(self) -> None:
         """Add Wikidata metadata."""
-        metadata = SourceMeta(src_name=SourceName.WIKIDATA.value,
-                              data_license='CC0 1.0',
-                              data_license_url='https://creativecommons.org/publicdomain/zero/1.0/',  # noqa: E501
-                              version=self._version,
-                              data_url=None,
-                              rdp_url=None,
-                              data_license_attributes={
-                                  'non_commercial': False,
-                                  'share_alike': False,
-                                  'attribution': False
-                              })
+        metadata = SourceMeta(
+            src_name=SourceName.WIKIDATA.value,
+            data_license="CC0 1.0",
+            data_license_url="https://creativecommons.org/publicdomain/zero/1.0/",
+            version=self._version,
+            data_url=None,
+            rdp_url=None,
+            data_license_attributes={
+                "non_commercial": False,
+                "share_alike": False,
+                "attribution": False
+            }
+        )
         params = dict(metadata)
-        params['src_name'] = SourceName.WIKIDATA.value
+        params["src_name"] = SourceName.WIKIDATA.value
         self.database.metadata.put_item(Item=params)
 
-    def _transform_data(self):
+    def _transform_data(self) -> None:
         """Transform the Wikidata source data."""
-        from therapy import XREF_SOURCES
-        with open(self._data_src, 'r') as f:
+        with open(self._src_file, "r") as f:
             records = json.load(f)
 
-            items = dict()
+            items: Dict[str, Any] = dict()
 
             for record in records:
-                record_id = record['item'].split('/')[-1]
+                record_id = record["item"].split("/")[-1]
                 concept_id = f"{NamespacePrefix.WIKIDATA.value}:{record_id}"
                 if concept_id not in items.keys():
-                    item = dict()
-                    item['label_and_type'] = f"{concept_id.lower()}##identity"
-                    item['item_type'] = 'identity'
-                    item['concept_id'] = concept_id
-                    item['src_name'] = SourceName.WIKIDATA.value
+                    item: Dict[str, Any] = {"concept_id": concept_id}
 
                     xrefs = list()
                     associated_with = list()
-                    for key in IDENTIFIER_PREFIXES.keys():
+                    for key in NAMESPACES.keys():
                         if key in record.keys():
                             ref = record[key]
 
-                            if key.upper() == 'CASREGISTRY':
+                            if key.upper() == "CASREGISTRY":
                                 key = SourceName.CHEMIDPLUS.value
 
                             if key.upper() in XREF_SOURCES:
-                                if key != 'chembl':
-                                    fmted_xref = \
-                                        f"{IDENTIFIER_PREFIXES[key]}:{SourceIDAfterNamespace[key.upper()].value}{ref}"  # noqa: E501
+                                if key != "chembl":
+                                    prefix = ID_PREFIXES.get(key.lower(), "")
+                                    fmted_xref = f"{NAMESPACES[key]}:{prefix}{ref}"
                                 else:
-                                    fmted_xref = \
-                                        f"{IDENTIFIER_PREFIXES[key]}:{ref}"
+                                    fmted_xref = f"{NAMESPACES[key]}:{ref}"
                                 xrefs.append(fmted_xref)
                             else:
-                                fmted_assoc = f"{IDENTIFIER_PREFIXES[key]}:" \
+                                fmted_assoc = f"{NAMESPACES[key]}:" \
                                               f"{ref}"
                                 associated_with.append(fmted_assoc)
-                    item['xrefs'] = xrefs
-                    item['associated_with'] = associated_with
-                    if 'itemLabel' in record.keys():
-                        item['label'] = record['itemLabel']
+                    item["xrefs"] = xrefs
+                    item["associated_with"] = associated_with
+                    if "itemLabel" in record.keys():
+                        item["label"] = record["itemLabel"]
                     items[concept_id] = item
-                if 'alias' in record.keys():
-                    if 'aliases' in items[concept_id].keys():
-                        items[concept_id]['aliases'].append(record['alias'])
+                if "alias" in record.keys():
+                    if "aliases" in items[concept_id].keys():
+                        items[concept_id]["aliases"].append(record["alias"])
                     else:
-                        items[concept_id]['aliases'] = [record['alias']]
+                        items[concept_id]["aliases"] = [record["alias"]]
 
         for item in items.values():
             self._load_therapy(item)
diff --git a/therapy/main.py b/therapy/main.py
index d7339751..100947e7 100644
--- a/therapy/main.py
+++ b/therapy/main.py
@@ -1,17 +1,20 @@
 """Main application for FastAPI"""
+import html
+from typing import Dict, Optional
+
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.openapi.utils import get_openapi
+
 from therapy import __version__
 from therapy.query import QueryHandler, InvalidParameterException
 from therapy.schemas import SearchService, NormalizationService
-from fastapi import FastAPI, HTTPException, Query
-from fastapi.openapi.utils import get_openapi
-import html
-from typing import Optional
+
 
 query_handler = QueryHandler()
-app = FastAPI(docs_url='/therapy', openapi_url='/therapy/openapi.json')
+app = FastAPI(docs_url="/therapy", openapi_url="/therapy/openapi.json")
 
 
-def custom_openapi():
+def custom_openapi() -> Dict:
     """Generate custom fields for OpenAPI response"""
     if app.openapi_schema:
         return app.openapi_schema
@@ -22,11 +25,11 @@ def custom_openapi():
         description="Normalize drugs and other therapy terms.",
         routes=app.routes
     )
-#    openapi_schema['info']['license'] = {  # TODO
+#    openapi_schema["info"]["license"] = {  # TODO
 #        "name": "Name-of-license",
 #        "url": "http://www.to-be-determined.com"
 #    }
-    openapi_schema['info']['contact'] = {  # TODO
+    openapi_schema["info"]["contact"] = {  # TODO
         "name": "Alex H. Wagner",
         "email": "Alex.Wagner@nationwidechildrens.org"
     }
@@ -34,27 +37,26 @@ def custom_openapi():
     return app.openapi_schema
 
 
-app.openapi = custom_openapi
+app.openapi = custom_openapi  # type: ignore
 
 # endpoint description text
-get_matches_summary = ("Given query, provide highest matches from "
-                       "each source.")
+get_matches_summary = "Given query, provide highest matches from each source."
 response_descr = "A response to a validly-formed query."
 q_descr = "Therapy to search."
-keyed_descr = ("If true, return response as key-value pairs of "
-               "sources to source matches.")
-incl_descr = ("Comma-separated list of source names to include in "
-              "response. Will exclude all other sources. Will return HTTP "
-              "status code 422: Unprocessable Entity if both 'incl' and "
-              "'excl' parameters are given.")
-excl_descr = ("Comma-separated list of source names to exclude in "
-              "response. Will include all other sources. Will return HTTP "
-              "status code 422: Unprocessable Entity if both 'incl' and"
-              "'excl' parameters are given.")
-search_description = ("For each source, return strongest-match concepts "
-                      "for query string provided by user")
-normalize_description = ("Return merged strongest-match concept for query "
-                         "string provided by user.")
+keyed_descr = ("If true, return response as key-value pairs of sources to source "
+               "matches.")
+incl_descr = ("Comma-separated list of source names to include in response. Will "
+              "exclude all other sources. Will return HTTP status code 422: "
+              'Unprocessable Entity if both "incl" and "excl" parameters are given.')
+excl_descr = ("Comma-separated list of source names to exclude in response. Will "
+              "include all other sources. Will return HTTP status code 422: "
+              'Unprocessable Entity if both "incl" and "excl" parameters are given.')
+infer_descr = ("If true, attempt namespace inference when queries match known "
+               "Local Unique Identifier patterns.")
+search_description = ("For each source, return strongest-match concepts for query "
+                      "string provided by user")
+normalize_description = ("Return merged strongest-match concept for query string "
+                         "provided by user.")
 
 
 @app.get("/therapy/search",
@@ -66,23 +68,27 @@ def custom_openapi():
 def search(q: str = Query(..., description=q_descr),
            keyed: Optional[bool] = Query(False, description=keyed_descr),
            incl: Optional[str] = Query(None, description=incl_descr),
-           excl: Optional[str] = Query(None, description=excl_descr)):
+           excl: Optional[str] = Query(None, description=excl_descr),
+           infer_namespace: bool = Query(True, description=infer_descr)
+           ) -> Dict:
     """For each source, return strongest-match concepts for query string
     provided by user.
 
-        :param q: therapy search term
-        :param keyed: if true, response is structured as key/value pair of
-            sources to source match lists.
-        :param incl: comma-separated list of sources to include, with all
-            others excluded. Raises HTTPException if both `incl` and
-            `excl` are given.
-        :param excl: comma-separated list of sources exclude, with all others
-            included. Raises HTTPException if both `incl` and `excl` are given.
-        :returns: JSON response with matched records and source metadata
+    :param q: therapy search term
+    :param bool keyed: if true, response is structured as key/value pair of sources to
+        source match lists.
+    :param str incl: comma-separated list of sources to include, with all others
+        excluded. Raises HTTPException if both `incl` and `excl` are given.
+    :param str excl: comma-separated list of sources exclude, with all others included.
+        Raises HTTPException if both `incl` and `excl` are given.
+    :param bool infer_namespace: if True, try to infer namespace from query term.
+    :returns: JSON response with matched records and source metadata
     """
     try:
-        response = query_handler.search_sources(html.unescape(q), keyed=keyed,
-                                                incl=incl, excl=excl)
+        response = query_handler.search_sources(html.unescape(q),
+                                                keyed=keyed,  # type: ignore
+                                                incl=incl, excl=excl,  # type: ignore
+                                                infer=infer_namespace)  # type: ignore
     except InvalidParameterException as e:
         raise HTTPException(status_code=422, detail=str(e))
     return response
@@ -99,14 +105,19 @@ def search(q: str = Query(..., description=q_descr),
          response_description=merged_response_descr,
          response_model=NormalizationService,
          description=normalize_description)
-def normalize(q: str = Query(..., description=merged_q_descr)):
+def normalize(q: str = Query(..., description=merged_q_descr),
+              infer_namespace: bool = Query(True, description=infer_descr)
+              ) -> Dict:
     """Return merged strongest-match concept for query string provided by
     user.
 
     :param q: therapy search term
+    :param bool infer_namespace: if True, try to infer namespace from query term.
+    :returns: JSON response with matching normalized record and source metadata
     """
     try:
-        response = query_handler.search_groups(html.unescape(q))
+        response = query_handler.search_groups(html.unescape(q),
+                                               infer_namespace)  # type: ignore
     except InvalidParameterException as e:
         raise HTTPException(status_code=422, detail=str(e))
     return response
diff --git a/therapy/query.py b/therapy/query.py
index e4ee4792..58d0a7fa 100644
--- a/therapy/query.py
+++ b/therapy/query.py
@@ -1,22 +1,23 @@
 """This module provides methods for handling queries."""
 import re
-from typing import List, Dict, Set, Optional
-from .version import __version__
-from therapy import SOURCES, NAMESPACE_LOOKUP, PROHIBITED_SOURCES, \
-    PREFIX_LOOKUP, ITEM_TYPES
+from typing import List, Dict, Set, Tuple, Union, Any, Optional
+from urllib.parse import quote
+from datetime import datetime
+
 from uvicorn.config import logger
+from botocore.exceptions import ClientError
+
+from therapy import SOURCES, PREFIX_LOOKUP, ITEM_TYPES, NAMESPACE_LUIS
 from therapy.database import Database
 from therapy.schemas import Drug, SourceMeta, MatchType, ServiceMeta, \
-    HasIndication, SourcePriority, SearchService, NormalizationService
-from botocore.exceptions import ClientError
-from urllib.parse import quote
-from datetime import datetime
+    HasIndication, SourcePriority, SearchService, NormalizationService, \
+    NamespacePrefix, SourceName
 
 
 class InvalidParameterException(Exception):
     """Exception for invalid parameter args provided by the user."""
 
-    def __init__(self, message):
+    def __init__(self, message: str) -> None:
         """Create new instance
 
         :param str message: string describing the nature of the error
@@ -25,11 +26,11 @@ def __init__(self, message):
 
 
 class QueryHandler:
-    """Class for normalizer management. Stores reference to database instance
-    and normalizes query input.
+    """Class for normalizer management. Stores reference to database instance and
+    normalizes query input.
     """
 
-    def __init__(self, db_url: str = '', db_region: str = 'us-east-2'):
+    def __init__(self, db_url: str = "", db_region: str = "us-east-2"):
         """Initialize Normalizer instance.
 
         :param str db_url: URL to database source.
@@ -37,20 +38,21 @@ def __init__(self, db_url: str = '', db_region: str = 'us-east-2'):
         """
         self.db = Database(db_url=db_url, region_name=db_region)
 
-    def _emit_warnings(self, query_str) -> Optional[Dict]:
+    def _emit_char_warnings(self, query_str: str) -> List[Dict]:
         """Emit warnings if query contains non breaking space characters.
 
         :param str query_str: query string
-        :return: dict keying warning type to warning description
+        :return: List of warnings (dicts)
         """
-        warnings = None
-        nbsp = re.search('\xa0|&nbsp;', query_str)
+        warnings: List[Dict[str, str]] = []
+        nbsp = re.search("\xa0|&nbsp;", query_str)
         if nbsp:
-            warnings = {
-                'nbsp': 'Query contains non breaking space characters.'
-            }
+            warnings = [{
+                "non_breaking_space_characters":
+                    "Query contains non-breaking space characters"
+            }]
             logger.warning(
-                f'Query ({query_str}) contains non breaking space characters.'
+                f"Query ({query_str}) contains non-breaking space characters."
             )
         return warnings
 
@@ -64,221 +66,258 @@ def _fetch_meta(self, src_name: str) -> SourceMeta:
             return self.db.cached_sources[src_name]
         else:
             try:
-                db_response = self.db.metadata.get_item(
-                    Key={'src_name': src_name}
-                )
-                response = SourceMeta(**db_response['Item'])
-                self.db.cached_sources[src_name] = response
-                return response
+                db_response = self.db.metadata.get_item(Key={"src_name": src_name})
             except ClientError as e:
-                logger.error(e.response['Error']['Message'])
+                msg = e.response["Error"]["Message"]
+                logger.error(msg)
+                raise Exception(msg)
+            try:
+                response = SourceMeta(**db_response["Item"])
+            except KeyError:
+                msg = f"Metadata lookup failed for source {src_name}"
+                logger.error(msg)
+                raise Exception(msg)
+            self.db.cached_sources[src_name] = response
+            return response
 
     def _add_record(self,
                     response: Dict[str, Dict],
                     item: Dict,
-                    match_type: str) -> (Dict, str):
+                    match_type: str) -> Tuple[Dict, str]:
         """Add individual record (i.e. Item in DynamoDB) to response object
 
-        :param Dict[str, Dict] response: in-progress response object to return
-            to client
+        :param Dict[str, Dict] response: in-progress response object
         :param Dict item: Item retrieved from DynamoDB
         :param MatchType match_type: type of query match
-        :return: Tuple containing updated response object, and string
-            containing name of the source of the match
+        :return: Tuple containing updated response object, and string containing name of
+            the source of the match
         """
-        inds = item.get('fda_indication')
+        inds = item.get("has_indication")
         if inds:
-            item['has_indication'] = [HasIndication(disease_id=i[0],
+            item["has_indication"] = [HasIndication(disease_id=i[0],
                                                     disease_label=i[1],
                                                     normalized_disease_id=i[2])
                                       for i in inds]
 
         drug = Drug(**item)
-        src_name = item['src_name']
+        src_name = item["src_name"]
 
-        matches = response['source_matches']
+        matches = response["source_matches"]
         if src_name not in matches.keys():
             pass
         elif matches[src_name] is None:
             matches[src_name] = {
-                'match_type': MatchType[match_type.upper()],
-                'records': [drug],
-                'source_meta_': self._fetch_meta(src_name)
+                "match_type": MatchType[match_type.upper()],
+                "records": [drug],
+                "source_meta_": self._fetch_meta(src_name)
             }
-        elif matches[src_name]['match_type'] == MatchType[match_type.upper()]:
-            matches[src_name]['records'].append(drug)
+        elif matches[src_name]["match_type"] == MatchType[match_type.upper()]:
+            matches[src_name]["records"].append(drug)
 
         return response, src_name
 
     def _fetch_records(self,
                        response: Dict[str, Dict],
                        concept_ids: Set[str],
-                       match_type: str) -> (Dict, Set):
-        """Return matched Drug records as a structured response for a given
-        collection of concept IDs.
-
-        :param Dict[str, Dict] response: in-progress response object to return
-            to client.
-        :param List[str] concept_ids: List of concept IDs to build from.
-            Should be all lower-case.
-        :param str match_type: record should be assigned this type of
-            match.
-        :return: response Dict with records filled in via provided concept
-            IDs, and Set of source names of matched records
+                       match_type: str) -> Tuple[Dict, Set]:
+        """Return matched Drug records as a structured response for a given collection
+        of concept IDs.
+
+        :param Dict[str, Dict] response: in-progress response object
+        :param List[str] concept_ids: List of concept IDs to build from.  Should be all
+            lower-case.
+        :param str match_type: record should be assigned this type of match.
+        :return: response Dict with records filled in via provided concept IDs, and Set
+            of source names of matched records
         """
         matched_sources = set()
         for concept_id in concept_ids:
             try:
                 match = self.db.get_record_by_id(concept_id.lower(),
                                                  case_sensitive=False)
+                assert match, f"Unable to retrieve record for {concept_id}"
                 (response, src) = self._add_record(response, match, match_type)
                 matched_sources.add(src)
             except ClientError as e:
-                logger.error(e.response['Error']['Message'])
+                logger.error(e.response["Error"]["Message"])
 
         return response, matched_sources
 
-    def _fill_no_matches(self, resp: Dict[str, Dict]) -> Dict:
+    def _fill_no_matches(self, resp: Dict[str, Any]) -> Dict:
         """Fill all empty source_matches slots with NO_MATCH results.
 
         :param Dict[str, Dict] resp: incoming response object
-        :return: response object with empty source slots filled with
-                NO_MATCH results and corresponding source metadata
+        :return: response object with empty source slots filled with NO_MATCH results
+            and corresponding source metadata
         """
-        for src_name in resp['source_matches'].keys():
-            if resp['source_matches'][src_name] is None:
-                resp['source_matches'][src_name] = {
-                    'match_type': MatchType.NO_MATCH,
-                    'records': [],
-                    'source_meta_': self._fetch_meta(src_name)
+        for src_name in resp["source_matches"].keys():
+            if resp["source_matches"][src_name] is None:
+                resp["source_matches"][src_name] = {
+                    "match_type": MatchType.NO_MATCH,
+                    "records": [],
+                    "source_meta_": self._fetch_meta(src_name)
                 }
         return resp
 
-    def _check_concept_id(self,
-                          query: str,
-                          resp: Dict,
-                          sources: Set[str]) -> (Dict, Set):
+    def _infer_namespace(self, query: str) -> Optional[Tuple[Dict, Dict]]:
+        """Retrieve concept ID by inferring namespace. Attempts to match given query
+        against known LUI patterns and performs concept ID lookup for all matches.
+        :param str query: user-provided query string
+        :return: Either tuple containing complete record and warnings if successful,
+        or None if unsuccessful
+        """
+        inferred_records = []
+        for pattern, source in NAMESPACE_LUIS:
+            match = re.match(pattern, query)
+            if match:
+                if source == SourceName.DRUGSATFDA.value:
+                    subspace, lui = match.groups()
+                    namespace = f"drugsatfda.{subspace.lower()}"
+                    inferred_id = f"{namespace}:{lui}"
+                else:
+                    namespace = NamespacePrefix[source.upper()].value
+                    inferred_id = f"{namespace}:{query}"
+                record = self.db.get_record_by_id(inferred_id, case_sensitive=False)
+                if record:
+                    inferred_records.append((record, namespace, inferred_id))
+        if inferred_records:
+            inferred_records.sort(key=lambda r: self._record_order(r[0]))
+            return (
+                inferred_records[0][0],
+                {
+                    "inferred_namespace": namespace,
+                    "adjusted_query": inferred_records[0][2],
+                    # probably not possible but just in case
+                    "alternate_inferred_matches": [i[2] for i in inferred_records[1:]]
+                }
+            )
+        else:
+            return None
+
+    def _check_concept_id(self, query: str, resp: Dict, sources: Set[str],
+                          infer: bool = True) -> Tuple[Dict, Set]:
         """Check query for concept ID match. Should only find 0 or 1 matches.
 
         :param str query: search string
         :param Dict resp: in-progress response object to return to client
         :param Set[str] sources: remaining unmatched sources
-        :return: Tuple with updated resp object and updated set of unmatched
-            sources
+        :param bool infer: if true, try to infer namespaces for IDs
+        :return: Tuple with updated resp object and updated set of unmatched sources
         """
-        concept_id_items = []
-        if [p for p in PREFIX_LOOKUP.keys() if query.startswith(p)]:
+        records = []
+        if infer:
+            infer_response = self._infer_namespace(query)
+            if infer_response:
+                records.append(infer_response[0])
+                resp["warnings"].append(infer_response[1])
+        query_lower = query.lower()
+        if [p for p in PREFIX_LOOKUP.keys() if query_lower.startswith(p)]:
             record = self.db.get_record_by_id(query, False)
             if record:
-                concept_id_items.append(record)
-        for prefix in [p for p in NAMESPACE_LOOKUP.keys()
-                       if query.startswith(p)]:
-            concept_id = f'{NAMESPACE_LOOKUP[prefix]}:{query}'
-            id_lookup = self.db.get_record_by_id(concept_id, False)
-            if id_lookup:
-                concept_id_items.append(id_lookup)
-        for item in concept_id_items:
+                records.append(record)
+        for item in records:
             (resp, src_name) = self._add_record(resp, item,
                                                 MatchType.CONCEPT_ID.name)
             sources = sources - {src_name}
         return resp, sources
 
-    def _check_match_type(self,
-                          query: str,
-                          resp: Dict,
-                          sources: Set[str],
-                          match_type: str) -> (Dict, Set):
+    def _check_match_type(self, query: str, resp: Dict, sources: Set[str],
+                          match_type: str) -> Tuple[Dict, Set]:
         """Check query for selected match type.
         :param str query: search string
         :param Dict resp: in-progress response object to return to client
         :param Set[str] sources: remaining unmatched sources
         :param str match_type: Match type to check for. Should be one of
-            {'trade_name', 'label', 'alias', 'xref', 'associated_with'}
+        {'trade_name', 'label', 'alias', 'xref', 'associated_with'}
         :return: Tuple with updated resp object and updated set of unmatched
-                 sources
+            sources
         """
         matches = self.db.get_records_by_type(query, match_type)
         if matches:
-            concept_ids = {i['concept_id'] for i in matches}
-            (resp, matched_srcs) = self._fetch_records(resp, concept_ids,
-                                                       match_type)
+            concept_ids = {i["concept_id"] for i in matches}
+            (resp, matched_srcs) = self._fetch_records(resp, concept_ids, match_type)
             sources = sources - matched_srcs
         return resp, sources
 
-    def _response_keyed(self, query: str, sources: Set[str]) -> Dict:
+    def _response_keyed(self, query: str, sources: Set[str],
+                        infer: bool = True) -> Dict:
         """Return response as dict where key is source name and value
         is a list of records. Corresponds to `keyed=true` API parameter.
 
         :param str query: string to match against
         :param Set[str] sources: sources to match from
+        :param bool infer: if true, attempt to infer namespaces from IDs
         :return: completed response object to return to client
         """
-        response = {
-            'query': query,
-            'warnings': self._emit_warnings(query),
-            'source_matches': {
+        response: Dict[str, Union[None, str, List[Dict], Dict]] = {
+            "query": query,
+            "warnings": self._emit_char_warnings(query),
+            "source_matches": {
                 source: None for source in sources
             }
         }
-        if query == '':
+        if query == "":
             response = self._fill_no_matches(response)
             return response
-        query = query.lower()
+        query = query.strip()
 
         # check if concept ID match
-        (response, sources) = self._check_concept_id(query, response, sources)
+        (response, sources) = self._check_concept_id(query, response, sources, infer)
         if len(sources) == 0:
             return response
 
+        query = query.lower()
         for match_type in ITEM_TYPES.values():
-            (response, sources) = self._check_match_type(query, response,
-                                                         sources, match_type)
+            (response, sources) = self._check_match_type(query, response, sources,
+                                                         match_type)
             if len(sources) == 0:
                 return response
 
         # remaining sources get no match
         return self._fill_no_matches(response)
 
-    def _response_list(self, query: str, sources: List[str]) -> Dict:
-        """Return response as list, where the first key-value in each item
-        is the source name. Corresponds to `keyed=false` API parameter.
+    def _response_list(self, query: str, sources: Set[str],
+                       infer: bool = True) -> Dict:
+        """Return response as list, where the first key-value in each item is the
+        source name. Corresponds to `keyed=false` API parameter.
 
         :param str query: string to match against
-        :param List[str] sources: sources to match from
+        :param Set[str] sources: sources to match from
+        :param bool infer: if true, attempt to infer namespaces from IDs
         :return: Completed response object to return to client
         """
-        response_dict = self._response_keyed(query, sources)
+        response_dict = self._response_keyed(query, sources, infer)
         source_list = []
-        for src_name in response_dict['source_matches'].keys():
-            src = {
-                "source": src_name,
-            }
-            to_merge = response_dict['source_matches'][src_name]
+        for src_name in response_dict["source_matches"].keys():
+            src = {"source": src_name}
+            to_merge = response_dict["source_matches"][src_name]
             src.update(to_merge)
 
             source_list.append(src)
-        response_dict['source_matches'] = source_list
+        response_dict["source_matches"] = source_list
 
         return response_dict
 
-    def search_sources(self, query_str, keyed=False, incl='', excl='') -> Dict:
+    def search_sources(self, query_str: str, keyed: bool = False, incl: str = "",
+                       excl: str = "", infer: bool = True) -> Dict:
         """Fetch normalized therapy objects.
 
         :param str query_str: query, a string, to search for
-        :param bool keyed: if true, return response as dict keying source names
-            to source objects; otherwise, return list of source objects
-        :param str incl: str containing comma-separated names of sources to
-            use. Will exclude all other sources. Case-insensitive.
-        :param str excl: str containing comma-separated names of source to
-            exclude. Will include all other source. Case-insensitive.
+        :param bool keyed: if true, return response as dict keying source names to
+            source objects; otherwise, return list of source objects
+        :param str incl: str containing comma-separated names of sources to use. Will
+            exclude all other sources. Case-insensitive.
+        :param str excl: str containing comma-separated names of source to exclude. Will
+            include all other source. Case-insensitive.
+        :param bool infer: if true, try to infer namespaces using known Local Unique
+            Identifier patterns
         :return: dict containing all matches found in sources.
-        :rtype: dict
-        :raises InvalidParameterException: if both incl and excl args are
-            provided, or if invalid source names are given.
+        :raises InvalidParameterException: if both incl and excl args are provided, or
+            if invalid source names are given.
         """
         sources = dict()
         for k, v in SOURCES.items():
-            if self.db.metadata.get_item(Key={'src_name': v}).get('Item'):
+            if self.db.metadata.get_item(Key={"src_name": v}).get("Item"):
                 sources[k] = v
         if not incl and not excl:
             query_sources = set(sources.values())
@@ -286,7 +325,7 @@ def search_sources(self, query_str, keyed=False, incl='', excl='') -> Dict:
             detail = "Cannot request both source inclusions and exclusions."
             raise InvalidParameterException(detail)
         elif incl:
-            req_sources = [n.strip() for n in incl.split(',')]
+            req_sources = [n.strip() for n in incl.split(",")]
             invalid_sources = []
             query_sources = set()
             for source in req_sources:
@@ -298,7 +337,7 @@ def search_sources(self, query_str, keyed=False, incl='', excl='') -> Dict:
                 detail = f"Invalid source name(s): {invalid_sources}"
                 raise InvalidParameterException(detail)
         else:
-            req_exclusions = [n.strip() for n in excl.lower().split(',')]
+            req_exclusions = [n.strip() for n in excl.lower().split(",")]
             req_excl_dict = {r.lower(): r for r in req_exclusions}
             invalid_sources = []
             query_sources = set()
@@ -312,17 +351,13 @@ def search_sources(self, query_str, keyed=False, incl='', excl='') -> Dict:
                 detail = f"Invalid source name(s): {invalid_sources}"
                 raise InvalidParameterException(detail)
 
-        query_str = query_str.strip()
-
         if keyed:
-            response = self._response_keyed(query_str, query_sources)
+            response = self._response_keyed(query_str, query_sources, infer)
         else:
-            response = self._response_list(query_str, query_sources)
+            response = self._response_list(query_str, query_sources, infer)
 
-        response['service_meta_'] = ServiceMeta(
-            version=__version__,
+        response["service_meta_"] = ServiceMeta(
             response_datetime=datetime.now(),
-            url="https://github.com/cancervariants/therapy-normalization"
         ).dict()
         return SearchService(**response).dict()
 
@@ -333,30 +368,29 @@ def _add_merged_meta(self, response: Dict) -> Dict:
         :return: completed response object.
         """
         sources_meta = {}
-        vod = response['therapy_descriptor']
-        ids = [vod['therapy_id']] + vod.get('xrefs', [])
+        vod = response["therapy_descriptor"]
+        ids = [vod["therapy_id"]] + vod.get("xrefs", [])
         for concept_id in ids:
-            prefix = concept_id.split(':')[0]
+            prefix = concept_id.split(":")[0]
             src_name = PREFIX_LOOKUP[prefix.lower()]
             if src_name not in sources_meta:
                 sources_meta[src_name] = self._fetch_meta(src_name)
-        response['source_meta_'] = sources_meta
+        response["source_meta_"] = sources_meta
         return response
 
-    def _record_order(self, record: Dict) -> (int, str):
+    def _record_order(self, record: Dict) -> Tuple[int, str]:
         """Construct priority order for matching. Only called by sort().
 
         :param Dict record: individual record item in iterable to sort
         :return: tuple with rank value and concept ID
         """
-        src = record['src_name'].upper()
+        src = record["src_name"].upper()
         source_rank = SourcePriority[src]
-        return source_rank, record['concept_id']
+        return source_rank, record["concept_id"]
 
     def _add_vod(self, response: Dict, record: Dict, query: str,
                  match_type: MatchType) -> Dict:
         """Format received DB record as VOD and update response object.
-
         :param Dict response: in-progress response object
         :param Dict record: record as stored in DB
         :param str query: query string from user request
@@ -364,72 +398,76 @@ def _add_vod(self, response: Dict, record: Dict, query: str,
         :return: completed response object ready to return to user
         """
         vod = {
-            'id': f'normalize.therapy:{quote(query.strip())}',
-            'type': 'TherapyDescriptor',
-            'therapy_id': record['concept_id'],
-            'label': record['label'],
-            'extensions': [],
+            "id": f"normalize.therapy:{quote(query.strip())}",
+            "type": "TherapyDescriptor",
+            "therapy_id": record["concept_id"],
+            "label": record.get("label"),
+            "extensions": [],
         }
-        if 'xrefs' in record:
-            xrefs = record['xrefs']
-            chembl_refs = [ref for ref in xrefs if ref.startswith('chembl')]
-            vod['xrefs'] = [ref for ref in xrefs
-                            if not ref.startswith('chembl')]
-        else:
-            chembl_refs = None
-        if 'aliases' in record:
-            vod['alternate_labels'] = record['aliases']
-
-        if any(filter(lambda f: f in record, ('approval_status',
-                                              'approval_year',
-                                              'fda_indication'))):
-            fda_approv = {
-                "name": "fda_approval",
+
+        if "xrefs" in record:
+            vod["xrefs"] = record["xrefs"]
+        if "aliases" in record:
+            vod["alternate_labels"] = record["aliases"]
+
+        if any(filter(lambda f: f in record, ("approval_rating",
+                                              "approval_ratings",
+                                              "approval_year",
+                                              "has_indication"))):
+            approv = {
+                "type": "Extension",
+                "name": "regulatory_approval",
                 "value": {}
             }
-            for field in ('approval_status', 'approval_year'):
-                value = record.get(field)
+            # temporarily handle rating vs ratings fields slightly differently
+            # to change in issue 223
+            if "approval_rating" in record:
+                value = record.get("approval_rating")
+                if value:
+                    approv["value"]["approval_ratings"] = [value]  # type: ignore
+            elif "approval_ratings" in record:
+                value = record.get("approval_ratings")
+                if value:
+                    approv["value"]["approval_ratings"] = value  # type: ignore
+            if "approval_year" in record:
+                value = record.get("approval_year")
                 if value:
-                    fda_approv['value'][field] = value
-            inds = record.get('fda_indication', [])
+                    approv["value"]["approval_year"] = value  # type: ignore
+            inds = record.get("has_indication", [])
             inds_list = []
             for ind in inds:
                 ind_obj = {
                     "id": ind[0],
                     "type": "DiseaseDescriptor",
-                    "label": ind[1]
+                    "label": ind[1],
+                    "disease_id": ind[2],
                 }
-                if ind[2]:
-                    ind_obj['disease_id']: ind[2]
-                    inds_list.append(ind_obj)
-                else:
-                    logger.warning(f"{ind[0]} has no disease ID")
+                inds_list.append(ind_obj)
             if inds_list:
-                fda_approv['value']['has_indication'] = inds_list
-            vod['extensions'].append(fda_approv)
+                approv["value"]["has_indication"] = inds_list  # type: ignore
+            vod["extensions"].append(approv)
 
-        for field, name in (('trade_names', 'trade_names'),
-                            ('associated_with', 'associated_with')):
+        for field, name in (("trade_names", "trade_names"),
+                            ("associated_with", "associated_with")):
             values = record.get(field)
 
             if values:
-                if (field == 'associated_with') and chembl_refs:
-                    values += chembl_refs
-                vod['extensions'].append({
-                    'type': 'Extension',
-                    'name': name,
-                    'value': values
+                vod["extensions"].append({
+                    "type": "Extension",
+                    "name": name,
+                    "value": values
                 })
 
-        if not vod['extensions']:
-            del vod['extensions']
+        if not vod["extensions"]:
+            del vod["extensions"]
 
-        response['match_type'] = match_type
-        response['therapy_descriptor'] = vod
+        response["match_type"] = match_type
+        response["therapy_descriptor"] = vod
         response = self._add_merged_meta(response)
         return response
 
-    def _handle_failed_merge_ref(self, record, response, query) -> Dict:
+    def _handle_failed_merge_ref(self, record: Dict, response: Dict,
+                                 query: str) -> Dict:
         """Log + fill out response for a failed merge reference lookup.
 
         :param Dict record: record containing failed merge_ref
@@ -439,80 +477,88 @@ def _handle_failed_merge_ref(self, record, response, query) -> Dict:
         """
         logger.error(f"Merge ref lookup failed for ref {record['merge_ref']} "
                      f"in record {record['concept_id']} from query `{query}`")
-        response['match_type'] = MatchType.NO_MATCH
+        response["match_type"] = MatchType.NO_MATCH
         return response
 
-    def search_groups(self, query: str) -> Dict:
+    def _resolve_merge(self, response: Dict, query: str,
+                       record: Dict, match_type: MatchType) -> Dict:
+        """Given a record, return the corresponding normalized record
+
+        :param Dict response: in-progress response object
+        :param str query: exact query as provided by user
+        :param Dict record: record to retrieve normalized concept for
+        :param MatchType match_type: type of match that returned these records
+        :return: formed response, a Dict
+        """
+        merge_ref = record.get("merge_ref")
+        if merge_ref:
+            # follow merge_ref
+            merge = self.db.get_record_by_id(merge_ref, False, True)
+            if merge is None:
+                return self._handle_failed_merge_ref(record, response, query)
+            else:
+                return self._add_vod(response, merge, query, match_type)
+        else:
+            # record is sole member of concept group
+            return self._add_vod(response, record, query, match_type)
+
+    def search_groups(self, query: str, infer: bool = True) -> Dict:
         """Return merged, normalized concept for given search term.
 
         :param str query: string to search against
+        :param bool infer: if true, try to infer namespace for IDs
         """
         # prepare basic response
-        response = {
-            'query': query,
-            'warnings': self._emit_warnings(query),
-            'service_meta_': ServiceMeta(
-                version=__version__,
+        response: Dict[str, Any] = {
+            "query": query,
+            "warnings": self._emit_char_warnings(query),
+            "service_meta_": ServiceMeta(
                 response_datetime=datetime.now(),
-                url="https://github.com/cancervariants/therapy-normalization"  # noqa: E501
             ).dict()
         }
-        if query == '':
-            response['match_type'] = MatchType.NO_MATCH
+        if query == "":
+            response["match_type"] = MatchType.NO_MATCH.value
             return response
         query_str = query.lower().strip()
 
         # check merged concept ID match
-        record = self.db.get_record_by_id(query_str, case_sensitive=False,
-                                          merge=True)
+        record = self.db.get_record_by_id(query_str, case_sensitive=False, merge=True)
         if record:
             return self._add_vod(response, record, query, MatchType.CONCEPT_ID)
 
         # check concept ID match
         record = self.db.get_record_by_id(query_str, case_sensitive=False)
-        if record and record['src_name'].lower() not in PROHIBITED_SOURCES:
-            merge_ref = record.get('merge_ref')
-            if not merge_ref:
-                return self._add_vod(response, record, query,
-                                     MatchType.CONCEPT_ID)
-            merge = self.db.get_record_by_id(merge_ref,
-                                             case_sensitive=False,
-                                             merge=True)
-            if merge is None:
-                return self._handle_failed_merge_ref(record, response,
-                                                     query_str)
-            else:
-                return self._add_vod(response, merge, query,
-                                     MatchType.CONCEPT_ID)
+        if record:
+            return self._resolve_merge(response, query, record,
+                                       MatchType.CONCEPT_ID)
+
+        # check concept ID match with inferred namespace
+        if infer:
+            inferred_response = self._infer_namespace(query)
+            if inferred_response:
+                response = self._resolve_merge(response, query,
+                                               inferred_response[0],
+                                               MatchType.CONCEPT_ID)
+                response["warnings"].append(inferred_response[1])
+                return response
 
         # check other match types
         for match_type in ITEM_TYPES.values():
             # get matches list for match tier
             matching_refs = self.db.get_records_by_type(query_str, match_type)
             matching_records = \
-                [self.db.get_record_by_id(m['concept_id'], False)
-                 for m in matching_refs
-                 if m['src_name'].lower() not in PROHIBITED_SOURCES]
-            matching_records.sort(key=self._record_order)
+                [self.db.get_record_by_id(m["concept_id"], False)
+                 for m in matching_refs]
+            matching_records.sort(key=self._record_order)  # type: ignore
 
             # attempt merge ref resolution until successful
             for match in matching_records:
-                record = self.db.get_record_by_id(match['concept_id'], False)
+                assert match is not None
+                record = self.db.get_record_by_id(match["concept_id"], False)
                 if record:
-                    merge_ref = record.get('merge_ref')
-                    if not merge_ref:
-                        return self._add_vod(response, record, query,
-                                             MatchType[match_type.upper()])
-                    merge = self.db.get_record_by_id(record['merge_ref'],
-                                                     case_sensitive=False,
-                                                     merge=True)
-                    if merge is None:
-                        return self._handle_failed_merge_ref(record, response,
-                                                             query_str)
-                    else:
-                        return self._add_vod(response, merge, query,
-                                             MatchType[match_type.upper()])
-
-        if not matching_records:
-            response['match_type'] = MatchType.NO_MATCH
+                    return self._resolve_merge(response, query, record,
+                                               MatchType[match_type.upper()])
+
+        if not matching_records:  # type: ignore
+            response["match_type"] = MatchType.NO_MATCH.value
         return NormalizationService(**response).dict()
diff --git a/therapy/schemas.py b/therapy/schemas.py
index 975cc439..d201e90d 100644
--- a/therapy/schemas.py
+++ b/therapy/schemas.py
@@ -1,70 +1,118 @@
-"""This module contains data models for representing VICC normalized
-therapy records.
-"""
-from typing import List, Optional, Dict, Union, Any, Type
-from pydantic import BaseModel, StrictBool
+"""This module contains data models for representing VICC therapy records."""
+from typing import List, Optional, Dict, Union, Any, Type, Set
 from enum import Enum, IntEnum
 from datetime import datetime
-from ga4gh.vrsatile.pydantic.vrsatile_model import ValueObjectDescriptor
-
-
-class Therapy(BaseModel):
-    """A procedure or substance used in the treatment of a disease."""
-
-    label: str
-    concept_id: str
-    aliases: Optional[List[str]] = []
-    xrefs: Optional[List[str]] = []
-    associated_with: Optional[List[str]] = []
-
-    class Config:
-        """Configure model"""
-
-        @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['Therapy']) -> None:
-            """Configure OpenAPI schema"""
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-
-
-class ApprovalStatus(str, Enum):
-    """Define string constraints for approval status attribute."""
-
-    WITHDRAWN = "withdrawn"
-    APPROVED = "approved"
-    INVESTIGATIONAL = "investigational"
 
+from ga4gh.vrsatile.pydantic.vrsatile_models import ValueObjectDescriptor
+from pydantic import BaseModel, StrictBool
 
-class PhaseEnum(IntEnum):
-    """An enumerated drug development phase type."""
+from therapy.version import __version__
+
+# Working structure for object in preparation for upload to DB
+RecordParams = Dict[str, Union[List, Set, str, Dict[str, Any]]]
+
+
+class ApprovalRating(str, Enum):
+    """Define string constraints for approval rating attribute. This reflects a drug's
+    regulatory approval status as evaluated by an individual source. We opted to retain
+    each individual source's rating as a distinct enum value after finding that some
+    sources disagreed on the true value (either because of differences in scope like
+    US vs EU/other regulatory arenas, old or conflicting data, or other reasons). Value
+    descriptions are provided below from each listed source.
+
+    ChEMBL:
+     - CHEMBL_PHASE_0: "Research: The compound has not yet reached clinical trials
+    (preclinical/research compound)"
+     - CHEMBL_PHASE_1: "The compound has reached Phase I clinical trials (safety
+    studies, usually with healthy volunteers)"
+     - CHEMBL_PHASE_2: "The compound has reached Phase II clinical trials (preliminary
+    studies of effectiveness)"
+     - CHEMBL_PHASE_3: "The compound has reached Phase III clinical trials (larger
+    studies of safety and effectiveness)"
+     - CHEMBL_PHASE_4: The compound has been approved in at least one country or area."
+     - CHEMBL_WITHDRAWN: "A withdrawn drug is an approved drug contained in a medicinal
+    product that subsequently had been removed from the market. The reasons for
+    withdrawal may include toxicity, lack of efficacy, or other reasons such as an
+    unfavorable risk-to-benefit ratio following approval and marketing of the drug.
+    ChEMBL considers an approved drug to be withdrawn only if all medicinal products
+    that contain the drug as an active ingredient have been withdrawn from one (or more)
+    regions of the world. Note that all medicinal products for a drug can be withdrawn
+    in one region of the world while still being marketed in other jurisdictions."
+    https://pubs.acs.org/doi/10.1021/acs.chemrestox.0c00296
+
+    Drugs@FDA:
+     - FDA_PRESCRIPTION: "A prescription drug product requires a doctor's authorization
+    to purchase."
+     - FDA_OTC: "FDA defines OTC drugs as safe and effective for use by the general
+    public without a doctor's prescription."
+     - FDA_DISCONTINUED: "approved products that have never been marketed, have been
+    discontinued from marketing, are for military use, are for export only, or have had
+    their approvals withdrawn for reasons other than safety or efficacy after being
+    discontinued from marketing"
+     - FDA_TENTATIVE: "If a generic drug product is ready for approval before the
+    expiration of any patents or exclusivities accorded to the reference listed drug
+    product, FDA issues a tentative approval letter to the applicant.  FDA delays final
+    approval of the generic drug product until all patent or exclusivity issues have
+    been resolved."
+    https://www.fda.gov/drugs/drug-approvals-and-databases/drugsfda-glossary-terms
+
+    HemOnc.org:
+    - HEMONC_APPROVED: Inferred by us if "Was FDA Approved Yr" property is present
+    (described as "Year of FDA approval")
+    https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6697579/
+
+    Guide to Pharmacology:
+    - GTOPDB_APPROVED: "Indicates pharmacologicaly active substances, specified by their
+    INNs, that have been approved for clinical use by a regulatory agency, typically the
+    FDA, EMA or in Japan. This classification persists regardless of whether the drug
+    may later have been withdrawn or discontinued. (N.B. in some cases the information
+    on approval status was obtained indirectly via databases such as Drugbank.)"
+    - GTOPDB_WITHDRAWN: "No longer approved for its original clinical use, as notified
+    by the FDA, typically as a consequence of safety or side effect issues."
+    https://www.guidetopharmacology.org/helpPage.jsp
+
+    RxNorm:
+    - RXNORM_PRESCRIBABLE: "The RxNorm Current Prescribable Content is a subset of
+    currently prescribable drugs found in RxNorm. We intend it to be an approximation of
+    the prescription drugs currently marketed in the US. The subset also includes many
+    over-the-counter drugs."
+    https://www.nlm.nih.gov/research/umls/rxnorm/docs/prescribe.html
+    """
 
-    preclinical = 0
-    phase_i_trials = 1
-    phase_ii_trials = 2
-    phase_iii_trials = 3
-    approved = 4
+    CHEMBL_0 = "chembl_phase_0"
+    CHEMBL_1 = "chembl_phase_1"
+    CHEMBL_2 = "chembl_phase_2"
+    CHEMBL_3 = "chembl_phase_3"
+    CHEMBL_4 = "chembl_phase_4"
+    CHEMBL_WITHDRAWN = "chembl_withdrawn"
+    FDA_OTC = "fda_otc"
+    FDA_PRESCRIPTION = "fda_prescription"
+    FDA_DISCONTINUED = "fda_discontinued"
+    FDA_TENTATIVE = "fda_tentative"
+    HEMONC_APPROVED = "hemonc_approved"
+    GTOPDB_APPROVED = "gtopdb_approved"
+    GTOPDB_WITHDRAWN = "gtopdb_withdrawn"
+    RXNORM_PRESCRIBABLE = "rxnorm_prescribable"
 
 
 class HasIndication(BaseModel):
-    """Data regarding FDA indication. Currently specific to HemOnc.org data."""
+    """Data regarding FDA indication. Currently provided only by HemOnc.org data."""
 
     disease_id: str
     disease_label: str
     normalized_disease_id: Optional[str]
 
     class Config:
-        """Configure Drug class"""
+        """Configure HasIndication class"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['Drug']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["HasIndication"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = [
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = [
                 {
                     "disease_id": "hemonc:671",
                     "disease_label": "Testicular cancer",
@@ -83,27 +131,30 @@ def schema_extra(schema: Dict[str, Any],
             ]
 
 
-class Drug(Therapy):
+class Drug(BaseModel):
     """A pharmacologic substance used to treat a medical condition."""
 
-    approval_status: Optional[ApprovalStatus] = None
+    concept_id: str
+    label: Optional[str] = None
+    aliases: Optional[List[str]] = []
+    trade_names: Optional[List[str]] = []
+    xrefs: Optional[List[str]] = []
+    associated_with: Optional[List[str]] = []
+    approval_rating: Optional[ApprovalRating] = None
     approval_year: Optional[List[int]] = []
     has_indication: Optional[List[HasIndication]] = []
-    trade_names: Optional[List[str]] = []
-    label: Optional[str] = None
 
     class Config:
         """Configure Drug class"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['Drug']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["Drug"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
                 "label": "CISPLATIN",
                 "concept_id": "chembl:CHEMBL11359",
                 "aliases": [
@@ -120,26 +171,15 @@ def schema_extra(schema: Dict[str, Any],
                 ],
                 "xrefs": [],
                 "associated_with": None,
-                "approval_status": "approved",
+                "approval_rating": "approved",
                 "approval_year": [],
                 "has_indication": [],
                 "trade_names": ["PLATINOL", "PLATINOL-AQ", "CISPLATIN"]
             }
 
 
-class DrugGroup(Therapy):
-    """A grouping of drugs based on common pharmacological attributes."""
-
-    description: str
-    type_identifier: str
-    drugs: List[Drug]
-
-
 class MatchType(IntEnum):
-    """Define string constraints for use in Match Type attributes.
-
-    Concept_ID=100; Label=80; Trade Name=80; Alias=60; Fuzzy=20; No Match=0
-    """
+    """Define string constraints for use in Match Type attributes."""
 
     CONCEPT_ID = 100
     LABEL = 80
@@ -158,8 +198,11 @@ class SourcePriority(IntEnum):
     NCIT = 2
     HEMONC = 3
     DRUGBANK = 4
-    CHEMIDPLUS = 5
-    WIKIDATA = 10
+    DRUGSATFDA = 5
+    GUIDETOPHARMACOLOGY = 6
+    CHEMBL = 7
+    CHEMIDPLUS = 8
+    WIKIDATA = 9
 
 
 class SourceName(str, Enum):
@@ -172,65 +215,54 @@ class SourceName(str, Enum):
     CHEMIDPLUS = "ChemIDplus"
     RXNORM = "RxNorm"
     HEMONC = "HemOnc"
-
-
-class SourceIDAfterNamespace(Enum):
-    """Define string constraints after namespace."""
-
-    WIKIDATA = "Q"
-    CHEMBL = "CHEMBL"
-    DRUGBANK = "DB"
-    NCIT = "C"
-    CHEMIDPLUS = ""
-    RXNORM = ""
-    HEMONC = ""
-
-
-class ProhibitedSources(Enum):
-    """Define constraints for sources that are prohibited in normalize
-    endpoint.
-    """
-
-    CHEMBL = SourceName.CHEMBL.value
+    DRUGSATFDA = "DrugsAtFDA"
+    DRUGSATFDA_NDA = DRUGSATFDA
+    DRUGSATFDA_ANDA = DRUGSATFDA
+    GUIDETOPHARMACOLOGY = "GuideToPHARMACOLOGY"
 
 
 class NamespacePrefix(Enum):
     """Define string constraints for namespace prefixes on concept IDs."""
 
+    ATC = "atc"  # Anatomical Therapeutic Chemical Classification System
+    BINDINGDB = "bindingdb"
+    CHEBI = "CHEBI"
+    CHEMBL = "chembl"
     CHEMIDPLUS = "chemidplus"
     CASREGISTRY = CHEMIDPLUS
-    PUBCHEMCOMPOUND = "pubchem.compound"
-    PUBCHEMSUBSTANCE = "pubchem.substance"
-    CHEMBL = "chembl"
-    RXNORM = "rxcui"
+    CHEMSPIDER = "chemspider"
+    CVX = "cvx"  # Vaccines Administered
     DRUGBANK = "drugbank"
-    WIKIDATA = "wikidata"
+    DRUGCENTRAL = "drugcentral"
+    DRUGSATFDA_ANDA = "drugsatfda.anda"
+    DRUGSATFDA_NDA = "drugsatfda.nda"
     HEMONC = "hemonc"
-    NCIT = "ncit"
-    FDA = "fda"
+    INCHIKEY = "inchikey"
     ISO = "iso"
-    UMLS = "umls"
-    CHEBI = "chebi"
+    IUPHAR = "iuphar"
+    IUPHAR_LIGAND = "iuphar.ligand"
+    GUIDETOPHARMACOLOGY = IUPHAR_LIGAND
     KEGGCOMPOUND = "kegg.compound"
     KEGGDRUG = "kegg.drug"
-    BINDINGDB = "bindingdb"
-    PHARMGKB = "pharmgkb.drug"
-    CHEMSPIDER = "chemspider"
-    ZINC = "zinc"
-    PDB = "pdb"
-    THERAPEUTICTARGETSDB = "ttd"
-    IUPHAR = "iuphar"
-    GUIDETOPHARMACOLOGY = "gtopdb"
-    INCHIKEY = "inchikey"
-    UNII = "unii"
-    ATC = "atc"  # Anatomical Therapeutic Chemical Classification System
-    CVX = "cvx"  # Vaccines Administered
     MMSL = "mmsl"  # Multum MediSource Lexicon
     MSH = "mesh"  # Medical Subject Headings
     MTHCMSFRF = "mthcmsfrf"  # CMS Formulary Reference File
-    MTHSPL = "mthspl"  # FDA Structured Product Labels
+    NCIT = "ncit"
+    NDC = "ndc"  # National Drug Code
+    PDB = "pdb"
+    PHARMGKB = "pharmgkb.drug"
+    PUBCHEMCOMPOUND = "pubchem.compound"
+    PUBCHEMSUBSTANCE = "pubchem.substance"
+    RXNORM = "rxcui"
+    SPL = "spl"  # Structured Product Labeling
+    THERAPEUTICTARGETSDB = "ttd"
+    UMLS = "umls"
+    UNII = "unii"
+    UNIPROT = "uniprot"
     USP = "usp"  # USP Compendial Nomenclature
     VANDF = "vandf"  # Veterans Health Administration National Drug File
+    WIKIDATA = "wikidata"
+    ZINC = "zinc"
 
 
 class DataLicenseAttributes(BaseModel):
@@ -245,11 +277,11 @@ class ItemTypes(str, Enum):
     """Item types used in DynamoDB."""
 
     # Must be in descending MatchType order.
-    LABEL = 'label'
-    TRADE_NAMES = 'trade_name'
-    ALIASES = 'alias'
-    XREFS = 'xref'
-    ASSOCIATED_WITH = 'associated_with'
+    LABEL = "label"
+    TRADE_NAMES = "trade_name"
+    ALIASES = "alias"
+    XREFS = "xref"
+    ASSOCIATED_WITH = "associated_with"
 
 
 class SourceMeta(BaseModel):
@@ -266,25 +298,24 @@ class Config:
         """Configure OpenAPI schema"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['SourceMeta']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["SourceMeta"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
-                'data_license': 'CC BY-SA 3.0',
-                'data_license_url':
-                    'https://creativecommons.org/licenses/by-sa/3.0/',
-                'version': '27',
-                'data_url':
-                    'http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/',  # noqa: E501
-                'rdp_url': 'http://reusabledata.org/chembl.html',
-                'data_license_attributes': {
-                    'non_commercial': False,
-                    'share_alike': True,
-                    'attribution': True
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
+                "data_license": "CC BY-SA 3.0",
+                "data_license_url":
+                    "https://creativecommons.org/licenses/by-sa/3.0/",
+                "version": "27",
+                "data_url":
+                    "http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/",  # noqa: E501
+                "rdp_url": "http://reusabledata.org/chembl.html",
+                "data_license_attributes": {
+                    "non_commercial": False,
+                    "share_alike": True,
+                    "attribution": True
                 }
             }
 
@@ -302,28 +333,27 @@ class Config:
         """Configure OpenAPI schema"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['MatchesKeyed']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["MatchesKeyed"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
-                'match_type': 0,
-                'records': [],
-                'source_meta_': {
-                    'data_license': 'CC BY-SA 3.0',
-                    'data_license_url':
-                        'https://creativecommons.org/licenses/by-sa/3.0/',
-                    'version': '27',
-                    'data_url':
-                        'http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/',  # noqa: E501
-                    'rdp_url': 'http://reusabledata.org/chembl.html',
-                    'data_license_attributes': {
-                        'non_commercial': False,
-                        'share_alike': True,
-                        'attribution': True
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
+                "match_type": 0,
+                "records": [],
+                "source_meta_": {
+                    "data_license": "CC BY-SA 3.0",
+                    "data_license_url":
+                        "https://creativecommons.org/licenses/by-sa/3.0/",
+                    "version": "27",
+                    "data_url":
+                        "http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/",  # noqa: E501
+                    "rdp_url": "http://reusabledata.org/chembl.html",
+                    "data_license_attributes": {
+                        "non_commercial": False,
+                        "share_alike": True,
+                        "attribution": True
                     }
                 },
             }
@@ -343,38 +373,39 @@ class Config:
         """Configure openAPI schema"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['MatchesListed']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["MatchesListed"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
-                'normalizer': 'ChEMBL',
-                'match_type': 0,
-                'records': [],
-                'source_meta_': {
-                    'data_license': 'CC BY-SA 3.0',
-                    'data_license_url':
-                        'https://creativecommons.org/licenses/by-sa/3.0/',
-                    'version': '27',
-                    'data_url':
-                        'http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/',  # noqa: E501
-                    'rdp_url': 'http://reusabledata.org/chembl.html',
-                    'data_license_attributes': {
-                        'non_commercial': False,
-                        'share_alike': True,
-                        'attribution': True
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
+                "normalizer": "ChEMBL",
+                "match_type": 0,
+                "records": [],
+                "source_meta_": {
+                    "data_license": "CC BY-SA 3.0",
+                    "data_license_url":
+                        "https://creativecommons.org/licenses/by-sa/3.0/",
+                    "version": "27",
+                    "data_url":
+                        "http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/",  # noqa: E501
+                    "rdp_url": "http://reusabledata.org/chembl.html",
+                    "data_license_attributes": {
+                        "non_commercial": False,
+                        "share_alike": True,
+                        "attribution": True
                     }
                 },
             }
 
 
-class FDAStatus(BaseModel):
-    """VOD Extension class for FDA status/indication attributes."""
+class ApprovalRatingValue(BaseModel):
+    """VOD Extension class for regulatory approval rating/indication
+    value attributes.
+    """
 
-    approval_status: Optional[ApprovalStatus]
+    approval_ratings: Optional[List[ApprovalRating]]
     approval_year: Optional[List[int]]
     has_indication: Optional[List[Union[Dict, ValueObjectDescriptor]]]
 
@@ -383,26 +414,25 @@ class ServiceMeta(BaseModel):
     """Metadata regarding the therapy-normalization service."""
 
     name = "thera-py"
-    version: str
+    version = __version__
     response_datetime: datetime
-    url: str
+    url = "https://github.com/cancervariants/therapy-normalization"
 
     class Config:
         """Configure OpenAPI schema"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['SourceMeta']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["SourceMeta"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
-                'name': 'thera-py',
-                'version': '0.1.0',
-                'response_datetime': '2021-04-05T16:44:15.367831',
-                'url': 'https://github.com/cancervariants/therapy-normalization'  # noqa: E501
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
+                "name": "thera-py",
+                "version": "0.1.0",
+                "response_datetime": "2021-04-05T16:44:15.367831",
+                "url": "https://github.com/cancervariants/therapy-normalization"
             }
 
 
@@ -410,7 +440,7 @@ class NormalizationService(BaseModel):
     """Response containing one or more merged records and source data."""
 
     query: str
-    warnings: Optional[Dict]
+    warnings: Optional[List[Dict]]
     match_type: MatchType
     therapy_descriptor: Optional[ValueObjectDescriptor]
     source_meta_: Optional[Dict[SourceName, SourceMeta]]
@@ -421,13 +451,13 @@ class Config:
 
         @staticmethod
         def schema_extra(schema: Dict[str, Any],
-                         model: Type['NormalizationService']) -> None:
+                         model: Type["NormalizationService"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
                 "query": "cisplatin",
                 "warnings": None,
                 "match_type": 80,
@@ -536,10 +566,10 @@ def schema_extra(schema: Dict[str, Any],
                     }
                 },
                 "service_meta_": {
-                    'name': 'thera-py',
-                    'version': '0.1.0',
-                    'response_datetime': '2021-04-05T16:44:15.367831',
-                    'url': 'https://github.com/cancervariants/therapy-normalization'  # noqa: E501
+                    "name": "thera-py",
+                    "version": "0.1.0",
+                    "response_datetime": "2021-04-05T16:44:15.367831",
+                    "url": "https://github.com/cancervariants/therapy-normalization"
                 }
             }
 
@@ -548,7 +578,7 @@ class SearchService(BaseModel):
     """Core response schema containing matches for each source"""
 
     query: str
-    warnings: Optional[Dict]
+    warnings: Optional[List[Dict]]
     source_matches: Union[Dict[SourceName, MatchesKeyed], List[MatchesListed]]
     service_meta_: ServiceMeta
 
@@ -556,14 +586,13 @@ class Config:
         """Enables orm_mode"""
 
         @staticmethod
-        def schema_extra(schema: Dict[str, Any],
-                         model: Type['SearchService']) -> None:
+        def schema_extra(schema: Dict[str, Any], model: Type["SearchService"]) -> None:
             """Configure OpenAPI schema"""
-            if 'title' in schema.keys():
-                schema.pop('title', None)
-            for prop in schema.get('properties', {}).values():
-                prop.pop('title', None)
-            schema['example'] = {
+            if "title" in schema.keys():
+                schema.pop("title", None)
+            for prop in schema.get("properties", {}).values():
+                prop.pop("title", None)
+            schema["example"] = {
                 "query": "cisplatin",
                 "warnings": None,
                 "source_matches": [
@@ -580,7 +609,7 @@ def schema_extra(schema: Dict[str, Any],
                                 ],
                                 "xrefs": ["drugbank:DB00515"],
                                 "associated_with": ["fda:Q20Q21Q62J"],
-                                "approval_status": None,
+                                "approval_rating": None,
                                 "trade_names": []
                             }
                         ],
@@ -588,7 +617,7 @@ def schema_extra(schema: Dict[str, Any],
                             "data_license": "custom",
                             "data_license_url": "https://www.nlm.nih.gov/databases/download/terms_and_conditions.html",  # noqa: E501
                             "version": "20210204",
-                            "data_url": "ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/",  # noqa: E501
+                            "data_url": "ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/",
                             "rdp_url": None,
                             "data_license_attributes": {
                                 "non_commercial": False,
@@ -634,7 +663,7 @@ def schema_extra(schema: Dict[str, Any],
                                     "mmsl:31747",
                                     "mmsl:4456"
                                 ],
-                                "approval_status": "approved",
+                                "approval_rating": "rxnorm_prescribable",
                                 "trade_names": [
                                     "Cisplatin",
                                     "Platinol"
@@ -668,7 +697,7 @@ def schema_extra(schema: Dict[str, Any],
                                     "fda:Q20Q21Q62J",
                                     "chebi:CHEBI:27899"
                                 ],
-                                "approval_status": None,
+                                "approval_rating": None,
                                 "trade_names": []
                             }
                         ],
@@ -709,7 +738,7 @@ def schema_extra(schema: Dict[str, Any],
                                 "associated_with": [
                                     "pubchem.compound:5702198"
                                 ],
-                                "approval_status": None,
+                                "approval_rating": None,
                                 "trade_names": []
                             }
                         ],
@@ -728,9 +757,9 @@ def schema_extra(schema: Dict[str, Any],
                     }
                 ],
                 "service_meta_": {
-                    'name': 'thera-py',
-                    'version': '0.1.0',
-                    'response_datetime': '2021-04-05T16:44:15.367831',
-                    'url': 'https://github.com/cancervariants/therapy-normalization'  # noqa: E501
+                    "name": "thera-py",
+                    "version": "0.1.0",
+                    "response_datetime": "2021-04-05T16:44:15.367831",
+                    "url": "https://github.com/cancervariants/therapy-normalization"
                 }
             }
diff --git a/therapy/version.py b/therapy/version.py
index 3b9e925d..316c9843 100644
--- a/therapy/version.py
+++ b/therapy/version.py
@@ -1,1 +1,2 @@
-__version__ = "0.2.26"
+"""Therapy normalizer version"""
+__version__ = "0.3.0-rc.1"
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..b5dcfe50
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,43 @@
+[tox]
+isolated_build = True
+envlist =
+    {py38,py39}-{test,lint,mypy}
+skipsdist = False
+
+[testenv]
+deps =
+    test: pytest
+    test: pytest-cov
+    lint: flake8
+    lint: flake8-docstrings
+    lint: flake8-quotes
+    lint: flake8-import-order
+    lint: flake8-annotations
+    mypy: mypy
+    mypy: types-PyYAML
+    mypy: types-requests
+setenv =
+    test: PY_IGNORE_IMPORTMISMATCH=1
+commands =
+    test: python3 -m pytest {posargs}
+    lint: flake8 therapy/ tests/ setup.py
+    mypy: mypy --ignore-missing-imports therapy/
+
+[testenv:githubactions]
+description="GitHub Actions-specific testing environment"
+deps =
+    pytest
+    pytest-cov
+setenv =
+    PY_IGNORE_IMPORTMISMATCH=1
+    TEST=TRUE
+    THERAPY_NORM_DB_URL=http://localhost:8001
+passenv =
+    AWS_ACCESS_KEY_ID
+    AWS_SECRET_ACCESS_KEY
+    AWS_DEFAULT_REGION
+    AWS_DEFAULT_OUTPUT
+    RXNORM_API_KEY
+commands =
+    {toxinidir}/tests/scripts/dynamodb_build.bash
+    python3 -m pytest {posargs}


Test patch:
diff --git a/analysis/approval_status_testing.ipynb b/analysis/approval_status_testing.ipynb
new file mode 100644
index 00000000..edce189d
--- /dev/null
+++ b/analysis/approval_status_testing.ipynb
@@ -0,0 +1,155 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "driven-camping",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "***Using Therapy Database Endpoint: http://localhost:8000***\n"
+     ]
+    }
+   ],
+   "source": [
+    "from therapy.query import QueryHandler\n",
+    "\n",
+    "q = QueryHandler('http://localhost:8000')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "collect-semiconductor",
+   "metadata": {},
+   "source": [
+    "# Defining 'approval status'\n",
+    "\n",
+    "## ChEMBL\n",
+    "\"Max Phase\":\n",
+    " * **Phase 0**: \"Research: The compound has not yet reached clinical trials (preclinical/research compound)\"\n",
+    " * **Phase 1**: \"The compound has reached Phase I clinical trials (safety studies, usually with healthy volunteers)\"\n",
+    " * **Phase 2**: \"The compound has reached Phase II clinical trials (preliminary studies of effectiveness)\"\n",
+    " * **Phase 3**: \"The compound has reached Phase III clinical trials (larger studies of safety and effectiveness)\"\n",
+    " * **Phase 4**: \"The compound has been approved in at least one country or area.\"\n",
+    " \n",
+    "Separately,\n",
+    " * **Withdrawn**: \"ChEMBL considers an approved drug to be withdrawn only if all medicinal products that contain the drug as an active ingredient have been withdrawn from one (or more) regions of the world. Note that all medicinal products for a drug can be withdrawn in one region of the world while still being marketed in other jurisdictions.\" (from https://pubs.acs.org/doi/10.1021/acs.chemrestox.0c00296)\n",
+    "\n",
+    "## RxNorm\n",
+    "\n",
+    " * **Current Prescribable Content**: \"The RxNorm Current Prescribable Content is a subset of currently prescribable drugs found in RxNorm. We intend it to be an approximation of the prescription drugs currently marketed in the US. The subset also includes many over-the-counter drugs.\" https://www.nlm.nih.gov/research/umls/rxnorm/docs/prescribe.html\n",
+    "\n",
+    "## HemOnc.org\n",
+    "\n",
+    " * **Discontinued**: \"Drugs that have lost FDA approval\" https://hemonc.org/wiki/Style_guide#Drugs_that_have_lost_FDA_approval. This is not actually included in the downloadable data -- for example, Mylotarg was withdrawn from the market but may still be worth investigating for other indications.\n",
+    " * **\"Was FDA approved yr\"**: Appears to just be a claim about (first?) FDA approval date. No indication of later withdrawal."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "advanced-physics",
+   "metadata": {},
+   "source": [
+    "Our existing normalization routines utilize the following ApprovalStatus enum and mappings:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "considered-legislature",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from enum import Enum\n",
+    "\n",
+    "def ExistingApprovalStatus(Enum):\n",
+    "    # ChEMBL \"withdrawn\" == True,\n",
+    "    WITHDRAWN = \"withdrawn\"\n",
+    "    \n",
+    "    # ChEMBL \"max_phase\" == 4,\n",
+    "    # RxNorm \"current_prescribable_content\" == True,\n",
+    "    # HemOnc \"was fda approved yr\" == not null,\n",
+    "    APPROVED = \"approved\"\n",
+    "    \n",
+    "    # ChEMBL \"max_phase\" == 1, 2, 3,\n",
+    "    INVESTIGATIONAL = \"investigational\"\n",
+    "    \n",
+    "    # ChEMBL \"max_phase\" == 0,\n",
+    "    # --> null (no value)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "hourly-occurrence",
+   "metadata": {},
+   "source": [
+    "## Drugs@FDA\n",
+    "\n",
+    "\"Marketing Status\":\n",
+    " * **Prescription**: \"A prescription drug product requires a doctor's authorization to purchase.\"\n",
+    " * **Over-the-counter**: \"FDA defines OTC drugs as safe and effective for use by the general public without a doctor's prescription.\"\n",
+    " * **Discontinued**: \"approved products that have never been marketed, have been discontinued from marketing, are for military use, are for export only, or have had their approvals withdrawn for reasons other than safety or efficacy after being discontinued from marketing\"\n",
+    " * **None (Tentatively Approved)**: \"If a generic drug product is ready for approval before the expiration of any patents or exclusivities accorded to the reference listed drug product, FDA issues a tentative approval letter to the applicant. FDA delays final approval of the generic drug product until all patent or exclusivity issues have been resolved. \"\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "three-taiwan",
+   "metadata": {},
+   "source": [
+    "## Sample drugs\n",
+    "\n",
+    "### trastuzumab\n",
+    "\n",
+    " * ChEMBL: max_phase == 4\n",
+    " * HemOnc: was FDA approved\n",
+    " * RxNorm: is current prescribable content\n",
+    "\n",
+    "Drugs@FDA:\n",
+    " * several applications -- all Prescription\n",
+    " \n",
+    "### cisplatin\n",
+    "\n",
+    " * ChEMBL: max_phase == 4\n",
+    " * HemOnc: was FDA approved\n",
+    " * RxNorm: is current prescribable content\n",
+    "\n",
+    "Drugs@FDA:\n",
+    " * ANDA074656: prescription"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "northern-mortality",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tests/conftest.py b/tests/conftest.py
index 1af63c72..d7c80c17 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,15 +1,46 @@
 """Pytest test config tools."""
-from therapy.schemas import Drug, MatchType
-from therapy.database import Database
+import os
 from typing import Dict, Any, Optional, List
 import json
-import pytest
 from pathlib import Path
 
+import pytest
+
+from therapy.schemas import Drug, MatchType
+from therapy.database import Database
+
 TEST_ROOT = Path(__file__).resolve().parents[1]
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module", autouse=True)
+def db():
+    """Create a DynamoDB test fixture."""
+
+    class DB:
+        def __init__(self):
+            self.db = Database()
+            if os.environ.get("TEST") is not None:
+                self.load_test_data()
+
+        def load_test_data(self) -> None:
+            with open(f"{TEST_ROOT}/tests/unit/"
+                      f"data/therapies.json", "r") as f:
+                therapies = json.load(f)
+                with self.db.therapies.batch_writer() as batch:
+                    for therapy in therapies:
+                        batch.put_item(Item=therapy)
+
+            with open(f"{TEST_ROOT}/tests/unit/"
+                      f"data/metadata.json", "r") as f:
+                metadata = json.load(f)
+                with self.db.metadata.batch_writer() as batch:
+                    for m in metadata:
+                        batch.put_item(Item=m)
+
+    return DB().db
+
+
+@pytest.fixture(scope="module")
 def mock_database():
     """Return MockDatabase object."""
 
@@ -17,22 +48,25 @@ class MockDatabase(Database):
         """Mock database object to use in test cases."""
 
         def __init__(self):
-            """Initialize mock database object. This class's method's shadow the
-            actual Database class methods.
+            """Initialize mock database object. This class's methods shadow the actual
+            Database class methods.
 
             `self.records` loads preexisting DB items.
-            `self.added_records` stores add record requests, with the
-            concept_id as the key and the complete record as the value.
-            `self.updates` stores update requests, with the concept_id as the
-            key, and a dict of {new_attribute: new_value} as the value.
+            `self.added_records` stores add record requests, with the concept_id as the
+            key and the complete record as the value.
+            `self.updates` stores update requests, with the concept_id as the key, and
+            a dict of {new_attribute: new_value} as the value.
             """
-            infile = TEST_ROOT / 'tests' / 'unit' / 'data' / 'therapies.json'
+            infile = TEST_ROOT / "tests" / "unit" / "data" / "therapies.json"
             self.records = {}
-            with open(infile, 'r') as f:
+            with open(infile, "r") as f:
                 records_json = json.load(f)
             for record in records_json:
-                label_and_type = record['label_and_type']
-                concept_id = record['concept_id']
+                try:
+                    label_and_type = record["label_and_type"]
+                except KeyError:
+                    raise Exception
+                concept_id = record["concept_id"]
                 if self.records.get(label_and_type):
                     self.records[label_and_type][concept_id] = record
                 else:
@@ -40,14 +74,14 @@ def __init__(self):
             self.added_records: Dict[str, Dict[Any, Any]] = {}
             self.updates: Dict[str, Dict[Any, Any]] = {}
 
-            meta = TEST_ROOT / 'tests' / 'unit' / 'data' / 'metadata.json'
-            with open(meta, 'r') as f:
+            meta = TEST_ROOT / "tests" / "unit" / "data" / "metadata.json"
+            with open(meta, "r") as f:
                 meta_json = json.load(f)
             self.cached_sources = {}
             for src in meta_json:
-                name = src['src_name']
+                name = src["src_name"]
                 self.cached_sources[name] = src
-                del self.cached_sources[name]['src_name']
+                del self.cached_sources[name]["src_name"]
 
         def get_record_by_id(self, record_id: str,
                              case_sensitive: bool = True,
@@ -55,21 +89,21 @@ def get_record_by_id(self, record_id: str,
             """Fetch record corresponding to provided concept ID.
 
             :param str record_id: concept ID for therapy record
-            :param bool case_sensitive: if true, performs exact lookup, which
-                is more efficient. Otherwise, performs filter operation, which
-                doesn't require correct casing.
+            :param bool case_sensitive: if true, performs exact lookup, which is more
+                efficient. Otherwise, performs filter operation, which doesn"t require
+                correct casing.
             :param bool merge: if true, retrieve merged record
             :return: complete therapy record, if match is found; None otherwise
             """
             if merge:
-                label_and_type = f'{record_id.lower()}##merger'
+                label_and_type = f"{record_id.lower()}##merger"
                 record_lookup = self.records.get(label_and_type)
                 if record_lookup:
                     return list(record_lookup.values())[0].copy()
                 else:
                     return None
             else:
-                label_and_type = f'{record_id.lower()}##identity'
+                label_and_type = f"{record_id.lower()}##identity"
             record_lookup = self.records.get(label_and_type, None)
             if record_lookup:
                 if case_sensitive:
@@ -92,16 +126,16 @@ def get_records_by_type(self, query: str,
                 "associated_with"} (use get_record_by_id for concept ID lookup)
             :return: list of matching records. Empty if lookup fails.
             """
-            assert match_type in ('alias', 'trade_name', 'label', 'rx_brand',
-                                  'xref', 'associated_with')
-            label_and_type = f'{query}##{match_type.lower()}'
+            assert match_type in ("alias", "trade_name", "label", "rx_brand",
+                                  "xref", "associated_with")
+            label_and_type = f"{query}##{match_type.lower()}"
             records_lookup = self.records.get(label_and_type, None)
             if records_lookup:
                 return [v.copy() for v in records_lookup.values()]
             else:
                 return []
 
-        def add_record(self, record: Dict, record_type: str):
+        def add_record(self, record: Dict, record_type: str) -> None:
             """Store add record request sent to database.
 
             :param Dict record: record (of any type) to upload. Must include
@@ -109,7 +143,7 @@ def add_record(self, record: Dict, record_type: str):
                 concept_id must be correctly-cased.
             :param str record_type: ignored by this function
             """
-            self.added_records[record['concept_id']] = record
+            self.added_records[record["concept_id"]] = record
 
         def update_record(self, concept_id: str, attribute: str,
                           new_value: Any):
@@ -119,31 +153,31 @@ def update_record(self, concept_id: str, attribute: str,
             :param str attribute: name of field to update
             :param str new_value: new value
             """
-            assert f'{concept_id.lower()}##identity' in self.records
+            assert f"{concept_id.lower()}##identity" in self.records
             self.updates[concept_id] = {attribute: new_value}
 
     return MockDatabase
 
 
-def compare_records(actual: Drug, fixture: Drug):
+def compare_records(actual: Dict, fixt: Drug):
     """Check that identity records are identical."""
-    fixture = fixture.dict()
-    assert actual['concept_id'] == fixture['concept_id']
-    assert actual['label'] == fixture['label']
-    assert set(actual['aliases']) == set(fixture['aliases'])
-    assert set(actual['trade_names']) == set(fixture['trade_names'])
-    assert set(actual['xrefs']) == set(fixture['xrefs'])
-    assert set(actual['associated_with']) == set(fixture['associated_with'])
-    if actual['approval_status'] or fixture['approval_status']:
-        assert actual['approval_status'] == fixture['approval_status']
-    if actual['approval_year'] or fixture['approval_year']:
-        assert set(actual['approval_year']) == set(fixture['approval_year'])
-    if actual['has_indication'] or fixture['has_indication']:
-        actual_inds = actual['has_indication'].copy()
-        fixture_inds = fixture['has_indication'].copy()
+    fixture = fixt.dict()
+    assert actual["concept_id"] == fixture["concept_id"]
+    assert actual["label"] == fixture["label"]
+    assert set(actual["aliases"]) == set(fixture["aliases"])
+    assert set(actual["trade_names"]) == set(fixture["trade_names"])
+    assert set(actual["xrefs"]) == set(fixture["xrefs"])
+    assert set(actual["associated_with"]) == set(fixture["associated_with"])
+    if actual["approval_rating"] or fixture["approval_rating"]:
+        assert actual["approval_rating"] == fixture["approval_rating"]
+    if actual["approval_year"] or fixture["approval_year"]:
+        assert set(actual["approval_year"]) == set(fixture["approval_year"])
+    if actual["has_indication"] or fixture["has_indication"]:
+        actual_inds = actual["has_indication"].copy()
+        fixture_inds = fixture["has_indication"].copy()
         assert len(actual_inds) == len(fixture_inds)
-        actual_inds.sort(key=lambda x: x['disease_id'])
-        fixture_inds.sort(key=lambda x: x['disease_id'])
+        actual_inds.sort(key=lambda x: x["disease_id"])
+        fixture_inds.sort(key=lambda x: x["disease_id"])
         for i in range(len(actual_inds)):
             assert actual_inds[i] == fixture_inds[i]
 
@@ -152,17 +186,15 @@ def compare_response(response: Dict, match_type: MatchType,
                      fixture: Drug = None, fixture_list: List[Drug] = None,
                      num_records: int = 0):
     """Check that test response is correct. Only 1 of {fixture, fixture_list}
-    should be passed as arguments. num_records should only be passed with
-    fixture_list.
+    should be passed as arguments. num_records should only be passed with fixture_list.
 
     :param Dict response: response object returned by QueryHandler
     :param MatchType match_type: expected match type
     :param Drug fixture: single Drug object to match response against
-    :param List[Drug] fixture_list: multiple Drug objects to match response
-        against
-    :param int num_records: expected number of records in response. If not
-        given, tests for number of fixture Drugs given (ie, 1 for single
-        fixture and length of fixture_list otherwise)
+    :param List[Drug] fixture_list: multiple Drug objects to match response against
+    :param int num_records: expected number of records in response. If not given, tests
+        for number of fixture Drugs given (ie, 1 for single fixture and length of
+        fixture_list otherwise)
     """
     if fixture and fixture_list:
         raise Exception("Args provided for both `fixture` and `fixture_list`")
@@ -172,17 +204,17 @@ def compare_response(response: Dict, match_type: MatchType,
         raise Exception("`num_records` should only be given with "
                         "`fixture_list`.")
 
-    assert response['match_type'] == match_type
+    assert response["match_type"] == match_type
     if fixture:
-        assert len(response['records']) == 1
-        compare_records(response['records'][0], fixture)
+        assert len(response["records"]) == 1
+        compare_records(response["records"][0], fixture)
     elif fixture_list:
         if not num_records:
-            assert len(response['records']) == len(fixture_list)
+            assert len(response["records"]) == len(fixture_list)
         else:
-            assert len(response['records']) == num_records
+            assert len(response["records"]) == num_records
         for fixt in fixture_list:
-            for record in response['records']:
+            for record in response["records"]:
                 if fixt.concept_id == record.concept_id:
                     compare_records(record, fixt)
                     break
diff --git a/tests/scripts/dynamodb_build.bash b/tests/scripts/dynamodb_build.bash
new file mode 100755
index 00000000..ed93da77
--- /dev/null
+++ b/tests/scripts/dynamodb_build.bash
@@ -0,0 +1,14 @@
+#! /bin/bash
+# check if dynamodb process already on port 8001
+IN_USE=$(ps aux | grep -E 'DynamoDBLocal.jar.*-port 8001' | wc -l)
+if [ $IN_USE -lt 2 ]; then
+    if [ ! -d tests/scripts/dynamodb_local ]; then
+        mkdir tests/scripts/dynamodb_local
+    fi
+    cd tests/scripts/dynamodb_local
+    if [ ! -e DynamoDBLocal.jar ]; then
+        curl -O "https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.tar.gz"
+        tar -xvzf dynamodb_local_latest.tar.gz; rm dynamodb_local_latest.tar.gz
+    fi
+    java -jar DynamoDBLocal.jar -port 8001 &
+fi
diff --git a/tests/unit/data/metadata.json b/tests/unit/data/metadata.json
index 5a42d5e6..539223ee 100644
--- a/tests/unit/data/metadata.json
+++ b/tests/unit/data/metadata.json
@@ -4,7 +4,7 @@
     "data_license": "CC BY-SA 3.0",
     "data_license_url": "https://creativecommons.org/licenses/by-sa/3.0/",
     "version": "27",
-    "data_url": "http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/",
+    "data_url": "ftp://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/",
     "rdp_url": "http://reusabledata.org/chembl.html",
     "data_license_attributes": {
       "non_commercial": false,
@@ -16,7 +16,7 @@
     "src_name": "Wikidata",
     "data_license": "CC0 1.0",
     "data_license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
-    "version": "20200812",
+    "version": "2020-08-12",
     "data_license_attributes": {
       "non_commercial": false,
       "share_alike": false,
@@ -27,8 +27,8 @@
     "src_name": "NCIt",
     "data_license": "CC BY 4.0",
     "data_license_url": "https://creativecommons.org/licenses/by/4.0/legalcode",
-    "version": "20.09d",
-    "data_url": "https://evs.nci.nih.gov/ftp1/NCI_Thesaurus/archive/2020/20.09d_Release/",
+    "version": "21.08e",
+    "data_url": "https://evs.nci.nih.gov/ftp1/NCI_Thesaurus/archive/2021/21.08e_Release/",
     "rdp_url": "http://reusabledata.org/ncit.html",
     "data_license_attributes": {
       "non_commercial": false,
@@ -53,7 +53,7 @@
     "src_name": "ChemIDplus",
     "data_license": "custom",
     "data_license_url": "https://www.nlm.nih.gov/databases/download/terms_and_conditions.html",
-    "version": "20210204",
+    "version": "2021-02-04",
     "data_url": "ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/",
     "rdp_url": null,
     "data_license_attributes": {
@@ -66,8 +66,8 @@
     "src_name": "RxNorm",
     "data_license": "UMLS Metathesaurus",
     "data_license_url": "https://www.nlm.nih.gov/research/umls/rxnorm/docs/termsofservice.html",
-    "version": "20210104",
-    "data_url": "https://www.nlm.nih.gov/research/umls/rxnorm/docs/rxnormfiles.html",
+    "version": "2021-01-04",
+    "data_url": "https://download.nlm.nih.gov/umls/kss/rxnorm/RxNorm_full_11012021.zip",
     "data_license_attributes": {
       "non_commercial": false,
       "share_alike": false,
@@ -78,12 +78,36 @@
     "src_name": "HemOnc",
     "data_license": "CC BY 4.0",
     "data_license_url": "https://creativecommons.org/licenses/by/4.0/legalcode",
-    "version": "20210225",
+    "version": "2021-02-25",
     "data_url": "https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/9CY9C6",
     "data_license_attributes": {
       "non_commercial": false,
       "share_alike": false,
       "attribution": true
     }
+  },
+  {
+    "src_name": "GuideToPHARMACOLOGY",
+    "data_license": "CC BY-SA 4.0",
+    "data_license_url": "https://creativecommons.org/licenses/by-sa/4.0/",
+    "version": "2021.3",
+    "data_url": "https://www.guidetopharmacology.org/download.jsp",
+    "data_license_attributes": {
+      "non_commercial": false,
+      "share_alike": true,
+      "attribution": true
+    }
+  },
+  {
+    "src_name": "DrugsAtFDA",
+    "data_license": "CC0",
+    "data_license_url": "https://creativecommons.org/publicdomain/zero/1.0/legalcode",
+    "version": "2021-10-04",
+    "data_url": "https://open.fda.gov/apis/drug/drugsfda/download/",
+    "data_license_attributes": {
+      "non_commercial": false,
+      "share_alike": false,
+      "attribution": false
+    }
   }
 ]
diff --git a/tests/unit/data/therapies.json b/tests/unit/data/therapies.json
index 888e4d2a..b018fc8b 100644
--- a/tests/unit/data/therapies.json
+++ b/tests/unit/data/therapies.json
@@ -7,7 +7,7 @@
     "label": "CISPLATIN",
     "aliases": [
       "Cisplatin",
-      "Cis-Platinum II",
+      "Cis-platinum ii",
       "Cisplatinum",
       "cis-diamminedichloroplatinum(II)",
       "CIS-DDP",
@@ -15,9 +15,10 @@
       "INT230-6 COMPONENT CISPLATIN",
       "NSC-119875",
       "Platinol",
-      "Platinol-Aq"
+      "Platinol-aq",
+      "Liplacis"
     ],
-    "approval_status": "approved",
+    "approval_rating": "chembl_phase_4",
     "trade_names": ["PLATINOL", "PLATINOL-AQ"]
   },
   {
@@ -35,11 +36,23 @@
     "concept_id": "chembl:chembl11359",
     "src_name": "ChEMBL"
   },
+  {
+    "label_and_type": "chembl:chembl2068237##identity",
+    "concept_id": "chembl:CHEMBL2068237",
+    "label": "CISPLATIN",
+    "approval_rating": "chembl_phase_4",
+    "src_name": "ChEMBL"
+  },
+  {
+    "label_and_type": "CISPLATIN##label",
+    "concept_id": "chembl:CHEMBL2068237",
+    "src_name": "ChEMBL"
+  },
   {
     "label_and_type": "chembl:chembl1628046##identity",
     "concept_id": "chembl:CHEMBL1628046",
     "label": "CISPLATIN",
-    "approval_status": "approved",
+    "approval_rating": "chembl_phase_4",
     "src_name": "ChEMBL"
   },
   {
@@ -51,7 +64,8 @@
     "label_and_type": "chembl:chembl267014##identity",
     "label": "L-745870",
     "concept_id": "chembl:CHEMBL267014",
-    "src_name": "ChEMBL"
+    "src_name": "ChEMBL",
+    "approval_rating": "chembl_phase_0"
   },
   {
     "label_and_type": "l-745870##label",
@@ -62,7 +76,7 @@
     "label_and_type": "chembl:chembl25##identity",
     "label": "ASPIRIN",
     "concept_id": "chembl:CHEMBL25",
-    "approval_status": "approved",
+    "approval_rating": "chembl_phase_4",
     "src_name": "ChEMBL"
   },
   {
@@ -70,6 +84,26 @@
     "concept_id": "chembl:chembl25",
     "src_name": "ChEMBL"
   },
+  {
+    "item_type": "identity",
+    "src_name": "DrugBank",
+    "label_and_type": "drugbank:db12117##identity",
+    "merge_ref": "rxcui:2555",
+    "label": "Mitometh",
+    "concept_id": "drugbank:DB12117",
+    "aliases": ["DDP"],
+    "xrefs": ["chemidplus:107465-03-2"],
+    "associated_with": [
+      "unii:H8MTN7XVC2",
+      "inchikey:MOTIYCLHZZLHHQ-UHFFFAOYSA-N"
+    ]
+  },
+  {
+    "label_and_type": "chemidplus:107465-03-2##xref",
+    "src_name": "DrugBank",
+    "concept_id": "drugbank:db12117",
+    "item_type": "xref"
+  },
   {
     "label_and_type": "drugbank:db00515##identity",
     "label": "Cisplatin",
@@ -124,13 +158,7 @@
     "label_and_type": "drugbank:db00522##identity",
     "label": "Bentiromide",
     "concept_id": "drugbank:DB00522",
-    "aliases": [
-      "APRD00818",
-      "Bentiromido",
-      "Bentiromidum",
-      "BTPABA",
-      "PFT"
-    ],
+    "aliases": ["APRD00818", "Bentiromido", "Bentiromidum", "BTPABA", "PFT"],
     "xrefs": ["chemidplus:37106-97-1"],
     "src_name": "DrugBank",
     "associated_with": [
@@ -268,7 +296,6 @@
       "rxcui:59744",
       "chemidplus:118390-30-0"
     ],
-    "associated_with": [],
     "src_name": "Wikidata"
   },
   {
@@ -361,11 +388,11 @@
       "VOGLIBOSE"
     ],
     "xrefs": ["chemidplus:83480-29-9"],
-    "associated_with": ["fda:S77P977AG8", "umls:C0532578"],
+    "associated_with": ["unii:S77P977AG8", "umls:C0532578"],
     "src_name": "NCIt"
   },
   {
-    "label_and_type": "fda:s77p977ag8##associated_with",
+    "label_and_type": "unii:s77p977ag8##associated_with",
     "concept_id": "ncit:c95221",
     "src_name": "NCIt"
   },
@@ -402,7 +429,7 @@
       "TP2001"
     ],
     "xrefs": ["chemidplus:197904-84-0"],
-    "associated_with": ["fda:5X5HB3VZ3Z", "umls:C1737955"],
+    "associated_with": ["unii:5X5HB3VZ3Z", "umls:C1737955"],
     "src_name": "NCIt"
   },
   {
@@ -412,7 +439,7 @@
     "item_type": "alias"
   },
   {
-    "label_and_type": "fda:5x5hb3vz3z##associated_with",
+    "label_and_type": "unii:5x5hb3vz3z##associated_with",
     "concept_id": "ncit:c74021",
     "src_name": "NCIt",
     "item_type": "associated_with"
@@ -432,11 +459,11 @@
     "label": "Trastuzumab",
     "concept_id": "ncit:C1647",
     "xrefs": ["chemidplus:180288-69-1"],
-    "associated_with": ["umls:C0728747", "fda:P188ANX8CK"],
+    "associated_with": ["umls:C0728747", "unii:P188ANX8CK"],
     "src_name": "NCIt"
   },
   {
-    "label_and_type": "fda:p188anx8ck##associated_with",
+    "label_and_type": "unii:p188anx8ck##associated_with",
     "concept_id": "ncit:c1647",
     "src_name": "NCIt"
   },
@@ -455,7 +482,7 @@
     "label": "Cisplatin",
     "concept_id": "ncit:C376",
     "xrefs": ["chemidplus:15663-27-1"],
-    "associated_with": ["umls:C0008838", "fda:Q20Q21Q62J"],
+    "associated_with": ["umls:C0008838", "unii:Q20Q21Q62J"],
     "src_name": "NCIt",
     "merge_ref": "rxcui:2555"
   },
@@ -469,13 +496,12 @@
     "concept_id": "chemidplus:87-08-1",
     "label": "Penicillin V",
     "aliases": ["Phenoxymethylpenicillin"],
-    "trade_names": [],
     "xrefs": ["drugbank:DB00417"],
-    "associated_with": ["fda:Z61I075U2W"],
+    "associated_with": ["unii:Z61I075U2W"],
     "src_name": "ChemIDplus"
   },
   {
-    "label_and_type": "fda:z61i075u2w##associated_with",
+    "label_and_type": "unii:z61i075u2w##associated_with",
     "concept_id": "chemidplus:87-08-1",
     "src_name": "ChemIDplus"
   },
@@ -498,14 +524,12 @@
     "label_and_type": "chemidplus:152459-95-5##identity",
     "concept_id": "chemidplus:152459-95-5",
     "label": "Imatinib",
-    "aliases": [],
-    "trade_names": [],
     "xrefs": ["drugbank:DB00619"],
-    "associated_with": ["fda:BKJ8M8G5HI"],
+    "associated_with": ["unii:BKJ8M8G5HI"],
     "src_name": "ChemIDplus"
   },
   {
-    "label_and_type": "fda:bkj8m8g5hi##associated_with",
+    "label_and_type": "unii:bkj8m8g5hi##associated_with",
     "concept_id": "chemidplus:152459-95-5",
     "src_name": "ChemIDplus"
   },
@@ -524,10 +548,8 @@
     "concept_id": "chemidplus:220127-57-1",
     "src_name": "ChemIDplus",
     "label": "Imatinib mesylate",
-    "aliases": [],
     "xrefs": ["drugbank:DB00619"],
-    "associated_with": ["fda:8A1O1M485B"],
-    "trade_names": []
+    "associated_with": ["unii:8A1O1M485B"]
   },
   {
     "label_and_type": "drugbank:db00619##xref",
@@ -543,9 +565,8 @@
       "cis-Diaminedichloroplatinum",
       "1,2-Diaminocyclohexaneplatinum II citrate"
     ],
-    "trade_names": [],
     "xrefs": ["drugbank:DB00515"],
-    "associated_with": ["fda:Q20Q21Q62J"],
+    "associated_with": ["unii:Q20Q21Q62J"],
     "src_name": "ChemIDplus",
     "merge_ref": "rxcui:2555"
   },
@@ -575,7 +596,7 @@
     "label_and_type": "rxcui:100213##identity",
     "concept_id": "rxcui:100213",
     "label": "Bifidobacterium Infantis",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": ["bifidobacterium infantis", "Bifidobacterium infantis"],
     "trade_names": ["Align", "Evivo"],
     "xrefs": ["drugbank:DB14222"],
@@ -612,7 +633,7 @@
     "label_and_type": "rxcui:2555##identity",
     "concept_id": "rxcui:2555",
     "label": "cisplatin",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "cis-Platinum",
       "CISplatin",
@@ -635,7 +656,7 @@
       "usp:m17910",
       "vandf:4018139",
       "mesh:D002945",
-      "mthspl:Q20Q21Q62J",
+      "unii:Q20Q21Q62J",
       "mmsl:d00195",
       "atc:L01XA01",
       "mmsl:31747",
@@ -684,7 +705,7 @@
     "label_and_type": "rxcui:644##identity",
     "concept_id": "rxcui:644",
     "label": "amiloride",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "3,5-diamino-N-carbamimidoyl-6-chloropyrazine-2-carboxamide",
       "aMILoride",
@@ -706,7 +727,7 @@
     "associated_with": [
       "mesh:D000584",
       "vandf:4019603",
-      "mthspl:7DZO8EB0Z3",
+      "unii:7DZO8EB0Z3",
       "mmsl:d00169",
       "atc:C03DB01"
     ],
@@ -741,7 +762,7 @@
     "label_and_type": "rxcui:10600##identity",
     "concept_id": "rxcui:10600",
     "label": "timolol",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "(S)-1-((1,1-Dimethylethyl)amino)-3-((4-(4-morpholinyl)-1,2,5-thiadazol-3-yl)oxy)-2-propanol",
       "2-Propanol, 1-((1,1-dimethylethyl)amino)-3-((4-(4-morpholinyl)-1,2,5-thiadiazol-3-yl)oxy)-, (S)-",
@@ -764,7 +785,7 @@
     "xrefs": ["drugbank:DB00373"],
     "associated_with": [
       "vandf:4019949",
-      "mthspl:817W3C6175",
+      "unii:817W3C6175",
       "mesh:D013999",
       "mmsl:d00139",
       "atc:C07AA06",
@@ -791,7 +812,7 @@
     "label_and_type": "rxcui:1011##identity",
     "concept_id": "rxcui:1011",
     "label": "lymphocyte immune globulin, anti-thymocyte globulin",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "Lymphocyte Immune Globulin, Anti-Thymocyte Globulin",
       "Anti-Thymocyte Globulin",
@@ -818,6 +839,82 @@
     ],
     "src_name": "RxNorm"
   },
+  {
+    "src_name": "RxNorm",
+    "label_and_type": "rxcui:107044##identity",
+    "item_type": "identity",
+    "label": "rabbit anti-human T-lymphocyte globulin",
+    "concept_id": "rxcui:107044",
+    "aliases": [
+      "Anti-human thymocyte immunoglobulin, rabbit",
+      "Rabbit anti-human thymocyte globulin (rATG)",
+      "Rabbit ATG",
+      "anti-thymocyte globulin (rabbit)",
+      "Anti-thymocyte globulin rabbit",
+      "Anti-thymocyte globulin (rabbit)",
+      "Rabbit anti-human thymocyte immunoglobulin",
+      "rATG",
+      "Rabbit antithymocyte globulin",
+      "Anti-T-lymphocyte immune globulin, rabbit",
+      "Rabbit anti-thymocyte immunoglobulin",
+      "Rabbit anti-human thymocyte globulin",
+      "Lapine T-lymphocyte immune globulin",
+      "Antithymocyte immunoglobulin"
+    ],
+    "xrefs": ["drugbank:DB00098"],
+    "associated_with": [
+      "atc:L04AA04",
+      "mesh:C512542",
+      "mthspl:D7RD81HE4W",
+      "mmsl:21336",
+      "mmsl:d07642"
+    ],
+    "approval_rating": "rxnorm_prescribable",
+    "trade_names": ["Thymoglobulin"]
+  },
+  {
+    "item_type": "trade_name",
+    "label_and_type": "thymoglobulin##trade_name",
+    "concept_id": "rxcui:107044",
+    "src_name": "RxNorm"
+  },
+  {
+    "src_name": "RxNorm",
+    "label_and_type": "rxcui:91601##identity",
+    "item_type": "identity",
+    "label": "lymphocyte immune globulin, anti-thymocyte globulin, equine",
+    "concept_id": "rxcui:91601",
+    "aliases": [
+      "Lymphocyte immune globulin, anti-thymocyte globulin",
+      "equine ATG",
+      "Anti-thymocyte globulin equine",
+      "Lymphocyte Immune Globulin, Anti-Thymocyte Globulin (Equine)",
+      "Horse ATG",
+      "eATG",
+      "lymphocyte immune globulin, anti-thy (equine)",
+      "Anti-thymocyte Globulin (Equine)",
+      "hATG",
+      "Equine thymocyte immune globulin",
+      "Lymphocyte immune globulin anti-thymocyte globulin (equine)",
+      "Horse anti-thymocyte globulin"
+    ],
+    "xrefs": ["drugbank:DB09312"],
+    "associated_with": [
+      "mthspl:475247QF1Z",
+      "mmsl:27222",
+      "mesh:D000961",
+      "mmsl:d07641",
+      "atc:L04AA03"
+    ],
+    "approval_rating": "rxnorm_prescribable",
+    "trade_names": ["ATGAM"]
+  },
+  {
+    "label_and_type": "atgam##trade_name",
+    "item_type": "trade_name",
+    "src_name": "RxNorm",
+    "concept_id": "rxcui:91601"
+  },
   {
     "label_and_type": "lymphocyte immune globulin, anti-thymocyte globulin##label",
     "concept_id": "rxcui:1011",
@@ -847,7 +944,7 @@
     "label_and_type": "rxcui:1191##identity",
     "concept_id": "rxcui:1191",
     "label": "aspirin",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "Salicylic acid acetate",
       "Aspirin",
@@ -866,7 +963,7 @@
       "usp:m6240",
       "vandf:4017536",
       "mmsl:34512",
-      "mthspl:R16CO5Y76E",
+      "unii:R16CO5Y76E",
       "mmsl:244",
       "mesh:D001241",
       "mmsl:4223",
@@ -896,7 +993,7 @@
     "label_and_type": "rxcui:44##identity",
     "concept_id": "rxcui:44",
     "label": "mesna",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "Mesnum",
       "Ethanesulfonic acid, 2-mercapto-, monosodium salt",
@@ -909,7 +1006,7 @@
       "usp:m49500",
       "mesh:D015080",
       "vandf:4019477",
-      "mthspl:NR7O1405Q9",
+      "unii:NR7O1405Q9",
       "atc:R05CB05",
       "atc:V03AF01",
       "mmsl:41498",
@@ -942,14 +1039,15 @@
     "label_and_type": "rxcui:61##identity",
     "concept_id": "rxcui:61",
     "label": "beta-alanine",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "beta Alanine",
       "3 Aminopropionic Acid",
       "3-Aminopropionic Acid"
     ],
-    "associated_with": ["mesh:D015091", "vandf:4028377", "mthspl:11P2JDE17B"],
-    "src_name": "RxNorm"
+    "associated_with": ["mesh:D015091", "vandf:4028377", "unii:11P2JDE17B"],
+    "src_name": "RxNorm",
+    "item_type": "identity"
   },
   {
     "label_and_type": "beta-alanine##label",
@@ -965,7 +1063,6 @@
     "label_and_type": "rxcui:595##identity",
     "concept_id": "rxcui:595",
     "label": "algestone",
-    "approval_status": null,
     "aliases": [
       "Pregn-4-ene-3,20-dione, 16,17-dihydroxy-, (16alpha)-",
       "16 alpha,17-Dihydroxypregn-4-ene-3,20-dione",
@@ -989,11 +1086,10 @@
     "label_and_type": "rxcui:10582##identity",
     "concept_id": "rxcui:10582",
     "label": "levothyroxine",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "3,5,3',5'-Tetraiodo-L-thyronine",
       "Thyroxine",
-      "thyroxine",
       "Thyroid Hormone, T4",
       "Thyroxin",
       "T4 Thyroid Hormone",
@@ -1009,34 +1105,12 @@
       "4-(4-Hydroxy-3,5-diiodophenoxy)-3,5-diiodo-L-phenylalanine",
       "Levothyroxin"
     ],
-    "trade_names": [
-      "Eltroxin",
-      "Estre",
-      "Euthyrox",
-      "Leventa",
-      "Levo-T",
-      "Levocrine",
-      "Levotabs",
-      "Levothroid",
-      "Levothyroid",
-      "Levoxyl",
-      "Novothyrox",
-      "SOLOXINE",
-      "Synthroid",
-      "Thyro-Tabs",
-      "ThyroKare",
-      "ThyroMed",
-      "Thyrolar",
-      "Thyrox",
-      "Tirosint",
-      "Unithroid"
-    ],
     "xrefs": ["drugbank:DB00451"],
     "associated_with": [
       "vandf:4022126",
       "mesh:D013974",
       "mmsl:d00278",
-      "mthspl:Q51BO43MG4"
+      "unii:Q51BO43MG4"
     ],
     "src_name": "RxNorm"
   },
@@ -1051,39 +1125,53 @@
     "src_name": "RxNorm"
   },
   {
-    "label_and_type": "unithroid##trade_name",
-    "concept_id": "rxcui:10582",
-    "src_name": "RxNorm"
+    "label_and_type": "chembl:chembl40##identity",
+    "label": "PHENOBARBITAL",
+    "concept_id": "chembl:CHEMBL40",
+    "aliases": [
+      "Luminal",
+      "Noptil",
+      "Phenobarbital civ",
+      "NSC-9848",
+      "Talpheno",
+      "Phenemal",
+      "Phenylethylmalonylurea",
+      "Solfoton",
+      "NSC-128143-",
+      "Phenobarbital",
+      "Phenobarbitone",
+      "Eskabarb",
+      "NSC-128143"
+    ],
+    "approval_rating": "chembl_phase_4",
+    "src_name": "ChEMBL",
+    "merge_ref": "rxcui:8134"
   },
   {
-    "label_and_type": "euthyrox##trade_name",
-    "concept_id": "rxcui:10582",
-    "src_name": "RxNorm"
+    "label_and_type": "PHENOBARBITAL##label",
+    "src_name": "ChEMBL",
+    "concept_id": "chembl:CHEMBL40"
   },
   {
     "label_and_type": "rxcui:8134##identity",
     "label": "Phenobarbital",
     "concept_id": "rxcui:8134",
     "aliases": [
-      "Phenylethylmalonylurea",
-      "Phenobarbital-containing product",
-      "Phenobarbital",
-      "Phenobarbitone",
-      "Acid, Phenylethylbarbituric",
-      "Phenobarbital (substance)",
+      "5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
       "PHENobarbital",
+      "Phenylethylbarbiturate",
+      "Phenobarbitone",
+      "Phenobarbitol",
       "Phenylethylbarbituric Acid",
-      "Phenylbarbital",
+      "Acid, Phenylethylbarbituric",
       "Phenemal",
-      "Phenylethylbarbiturate",
-      "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione",
+      "Phenylethylmalonylurea",
       "5-Phenyl-5-ethylbarbituric acid",
-      "5-Ethyl-5-phenylbarbituric acid",
       "Phenobarbituric Acid",
-      "5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
+      "Phenylbarbital",
       "5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione",
-      "Phenobarbitol",
-      "Product containing phenobarbital (medicinal product)"
+      "5-Ethyl-5-phenylbarbituric acid",
+      "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione"
     ],
     "xrefs": ["drugbank:DB01174"],
     "associated_with": [
@@ -1095,13 +1183,13 @@
       "msh:D010634",
       "snomedct:373505007",
       "mmsl:5272",
-      "mthspl:YQE403BP4D",
+      "unii:YQE403BP4D",
       "fdbmk:001406",
       "mmsl:d00340",
       "atc:N03AA02"
     ],
-    "approval_status": null,
     "src_name": "RxNorm",
+    "approval_rating": "rxnorm_prescribable",
     "merge_ref": "rxcui:8134"
   },
   {
@@ -1133,11 +1221,8 @@
     "label_and_type": "chemidplus:50-06-6##identity",
     "label": "Phenobarbital",
     "concept_id": "chemidplus:50-06-6",
-    "aliases": [],
     "xrefs": ["drugbank:DB01174"],
-    "associated_with": ["fda:YQE403BP4D"],
-    "approval_status": null,
-    "trade_names": [],
+    "associated_with": ["unii:YQE403BP4D"],
     "src_name": "ChemIDplus",
     "merge_ref": "rxcui:8134"
   },
@@ -1148,36 +1233,32 @@
     "item_type": "identity",
     "label": "Spiramycin",
     "concept_id": "chemidplus:8025-81-8",
-    "aliases": [],
-    "xrefs": [],
-    "associated_with": ["fda:71ODY0V87H"],
-    "approval_status": null,
-    "trade_names": []
+    "associated_with": ["unii:71ODY0V87H"]
   },
   {
     "label": "phenobarbital",
     "label_and_type": "wikidata:q407241##identity",
     "concept_id": "wikidata:Q407241",
     "aliases": [
-      "phenylethylbarbiturate",
-      "fenobarbital",
       "Phenobarbitol",
+      "fenobarbital",
       "Phenylethylbarbituric Acid",
-      "5-Ethyl-5-phenylbarbituric acid",
-      "Phenylethylbarbiturate",
-      "Phenylethylbarbitursaeure",
+      "phenylethylbarbiturate",
       "Phenylethylmalonylurea",
+      "5-Phenyl-5-ethylbarbituric acid",
       "Phenobarbital",
+      "Phenylethylbarbitursaeure",
       "Phenobarbitone",
-      "Phenylaethylbarbitursaeure",
-      "5-Phenyl-5-ethylbarbituric acid",
-      "Phenobarbituric Acid",
+      "Luminal®",
       "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione",
       "5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
-      "Luminal®",
       "phenobarbital sodium",
+      "Phenylethylbarbiturate",
+      "Phenylaethylbarbitursaeure",
+      "PHENYLETHYLMALONYLUREA",
+      "Phenobarbituric Acid",
       "5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione",
-      "PHENYLETHYLMALONYLUREA"
+      "5-Ethyl-5-phenylbarbituric acid"
     ],
     "xrefs": [
       "chemidplus:50-06-6",
@@ -1186,8 +1267,6 @@
       "drugbank:DB01174"
     ],
     "associated_with": ["pubchem.compound:4763"],
-    "approval_status": null,
-    "trade_names": [],
     "src_name": "Wikidata",
     "merge_ref": "rxcui:8134"
   },
@@ -1202,9 +1281,7 @@
       "PHENO"
     ],
     "xrefs": ["chemidplus:50-06-6"],
-    "associated_with": ["umls:C0031412", "fda:YQE403BP4D", "chebi:CHEBI:8069"],
-    "approval_status": null,
-    "trade_names": [],
+    "associated_with": ["umls:C0031412", "unii:YQE403BP4D", "CHEBI:8069"],
     "src_name": "NCIt",
     "merge_ref": "rxcui:8134"
   },
@@ -1227,9 +1304,7 @@
       "(4R,5S,6R,7R,9R,10R,11E,13E,16R)-10-{[(2R,5S,6R)-5-(dimethylamino)-6-methyltetrahydro-2H-pyran-2-yl]oxy}-9,16-dimethyl-5-methoxy-2-oxo-7-(2-oxoethyl)oxacyclohexadeca-11,13-dien-6-yl 3,6-dideoxy-4-O-(2,6-dideoxy-3-C-methyl-alpha-L-ribo-hexopyranosyl)-3-(dimethylamino)-alpha-D-glucopyranoside"
     ],
     "xrefs": ["chemidplus:8025-81-8"],
-    "associated_with": ["umls:C0037962", "fda:71ODY0V87H"],
-    "approval_status": null,
-    "trade_names": [],
+    "associated_with": ["umls:C0037962", "unii:71ODY0V87H"],
     "src_name": "NCIt",
     "merge_ref": "ncit:c839"
   },
@@ -1252,55 +1327,75 @@
       "ncit:C739",
       "drugbank:DB01174",
       "chemidplus:50-06-6",
-      "wikidata:Q407241"
+      "wikidata:Q407241",
+      "iuphar.ligand:2804",
+      "chembl:CHEMBL40"
     ],
     "aliases": [
-      "PHENO",
-      "Phenylethylmalonylurea",
-      "PHENYLETHYLMALONYLUREA",
-      "5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione",
-      "Phenylethylbarbitursaeure",
       "5-Ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
-      "5-Phenyl-5-ethylbarbituric acid",
-      "PHENobarbital",
-      "Phenyläthylbarbitursäure",
-      "fenobarbital",
-      "Phenylethylbarbiturate",
+      "5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione",
       "5-Ethyl-5-phenylbarbituric acid",
-      "Phenobarbitol",
+      "5-Phenyl-5-ethylbarbituric acid",
       "5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
+      "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione",
+      "APRD00184",
+      "Acid, Phenylethylbarbituric",
       "Fenobarbital",
-      "phenobarbital",
-      "Phenobarbituric Acid",
       "Luminal®",
-      "Acid, Phenylethylbarbituric",
+      "PHENO",
+      "PHENOBARBITAL",
+      "PHENYLETHYLMALONYLUREA",
+      "PHENobarbital",
+      "Phenemal",
       "Phenobarbital",
+      "Phenobarbitol",
+      "Phenobarbitone",
+      "Phenobarbituric Acid",
+      "Phenylaethylbarbitursaeure",
       "Phenylbarbital",
-      "phenobarbital sodium",
-      "Phenemal",
-      "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione",
-      "APRD00184",
-      "PHENOBARBITAL",
+      "Phenylethylbarbiturate",
+      "Phenylethylbarbituric Acid",
+      "Phenylethylbarbitursaeure",
       "Phenylethylbarbitursäure",
+      "Phenylethylmalonylurea",
+      "Phenyläthylbarbitursäure",
+      "fenobarbital",
+      "phenobarbital",
+      "phenobarbital sodium",
       "phenylethylbarbiturate",
-      "Phenylethylbarbituric Acid",
-      "Phenylaethylbarbitursaeure",
-      "Phenobarbitone"
+      "5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione",
+      "phenobarb",
+      "phenobarbitone",
+      "NSC-128143",
+      "NSC-128143-",
+      "NSC-9848",
+      "Phenobarbital civ",
+      "Solfoton",
+      "Luminal",
+      "Eskabarb",
+      "Noptil",
+      "Talpheno"
     ],
     "associated_with": [
+      "usp:m63400",
       "vandf:4017422",
-      "mthspl:YQE403BP4D",
-      "atc:N03AA02",
-      "chebi:CHEBI:8069",
       "mmsl:2390",
-      "mmsl:d00340",
-      "pubchem.compound:4763",
       "mesh:D010634",
       "mmsl:5272",
+      "mmsl:d00340",
+      "atc:N03AA02",
+      "unii:YQE403BP4D",
       "umls:C0031412",
-      "fda:YQE403BP4D",
-      "usp:m63400",
-      "chembl:CHEMBL40"
+      "CHEBI:8069",
+      "pubchem.substance:135650817",
+      "pubchem.compound:4763",
+      "inchikey:DDBREPKUVSBGFI-UHFFFAOYSA-N",
+      "drugcentral:2134"
+    ],
+    "approval_ratings": [
+      "rxnorm_prescribable",
+      "chembl_phase_4",
+      "gtopdb_approved"
     ]
   },
   {
@@ -1310,11 +1405,19 @@
       "ncit:C376",
       "hemonc:105",
       "drugbank:DB00515",
+      "drugbank:DB12117",
       "chemidplus:15663-27-1",
+      "iuphar.ligand:5343",
+      "chembl:CHEMBL11359",
       "wikidata:Q412415",
-      "wikidata:Q47522001"
+      "drugsatfda.anda:074656",
+      "drugsatfda.anda:074735",
+      "drugsatfda.anda:206774",
+      "drugsatfda.anda:207323",
+      "drugsatfda.anda:075036",
+      "drugsatfda.nda:018057"
     ],
-    "trade_names": ["Cisplatin", "Platinol"],
+    "trade_names": ["Cisplatin", "Platinol", "PLATINOL", "PLATINOL-AQ"],
     "aliases": [
       "1,2-Diaminocyclohexaneplatinum II citrate",
       "APRD00359",
@@ -1330,36 +1433,77 @@
       "cis Platinum",
       "cis-Diaminedichloroplatinum",
       "cis-Diamminedichloroplatinum",
-      "cis-diamminedichloroplatinum(II)",
       "cis-Diamminedichloroplatinum(II)",
       "cis-Dichlorodiammineplatinum(II)",
       "cis-Platinum",
-      "cisplatino",
       "cis-diamminedichloroplatinum(II)",
       "Platinol-AQ",
-      "Platinol"
+      "Platinol",
+      "cisplatino",
+      "Cisplatin",
+      "Cis-platinum ii",
+      "Cisplatinum",
+      "INT-230-6 COMPONENT CISPLATIN",
+      "INT230-6 COMPONENT CISPLATIN",
+      "NSC-119875",
+      "Platinol-aq",
+      "DACP",
+      "NSC 119875",
+      "cis-diamminedichloroplatinum III",
+      "cis-platinum",
+      "cisplatinum",
+      "Liplacis"
     ],
     "label": "cisplatin",
     "associated_with": [
-      "umls:C0008838",
-      "fda:Q20Q21Q62J",
+      "mmsl:31747",
+      "mmsl:4456",
+      "mmsl:d00195",
       "usp:m17910",
-      "vandf:4018139",
+      "CHEBI:27899",
+      "inchikey:LXZZYRPGZAFOLE-UHFFFAOYSA-L",
+      "inchikey:MOTIYCLHZZLHHQ-UHFFFAOYSA-N",
       "mesh:D002945",
-      "mthspl:Q20Q21Q62J",
-      "mmsl:d00195",
       "atc:L01XA01",
-      "mmsl:31747",
-      "mmsl:4456",
+      "vandf:4018139",
+      "pubchem.substance:178102005",
+      "pubchem.compound:441203",
       "pubchem.compound:5702198",
+      "umls:C0008838",
+      "unii:H8MTN7XVC2",
       "unii:Q20Q21Q62J",
-      "inchikey:LXZZYRPGZAFOLE-UHFFFAOYSA-L",
-      "chebi:CHEBI:27899",
-      "chembl:CHEMBL11359"
+      "ndc:0143-9504",
+      "ndc:0143-9505",
+      "ndc:0703-5747",
+      "ndc:0703-5748",
+      "ndc:16729-288",
+      "ndc:44567-509",
+      "ndc:44567-510",
+      "ndc:44567-511",
+      "ndc:44567-530",
+      "ndc:63323-103",
+      "ndc:68001-283",
+      "ndc:68083-162",
+      "ndc:68083-163",
+      "ndc:70860-206",
+      "spl:01c7a680-ee0d-42da-85e8-8d56c6fe7006",
+      "spl:5a24d5bd-c44a-43f7-a04c-76caf3475012",
+      "spl:a66eda32-1164-439a-ac8e-73138365ec06",
+      "spl:dd45d777-d4c1-40ee-b4f0-c9e001a15a8c",
+      "spl:2c569ef0-588f-4828-8b2d-03a2120c9b4c",
+      "spl:54b3415c-c095-4c82-b216-e0e6e6bb8d03",
+      "spl:9b008181-ab66-db2f-e053-2995a90aad57",
+      "spl:c3ddc4a5-9f1b-a8ee-e053-2a95a90a2265",
+      "spl:c43de769-d6d8-3bb9-e053-2995a90a5aa2"
+    ],
+    "approval_ratings": [
+      "rxnorm_prescribable",
+      "hemonc_approved",
+      "fda_prescription",
+      "chembl_phase_4"
     ],
-    "approval_status": "approved",
     "approval_year": ["1978"],
-    "fda_indication": [
+    "has_indication": [
       ["hemonc:671", "Testicular cancer", "ncit:C7251"],
       ["hemonc:645", "Ovarian cancer", "ncit:C7431"],
       ["hemonc:569", "Bladder cancer", "ncit:C9334"]
@@ -1369,7 +1513,7 @@
     "label_and_type": "ncit:c839##merger",
     "item_type": "merger",
     "concept_id": "ncit:C839",
-    "xrefs": ["chemidplus:8025-81-8"],
+    "xrefs": ["chemidplus:8025-81-8", "wikidata:Q422265"],
     "label": "Spiramycin",
     "aliases": [
       "SPIRAMYCIN",
@@ -1378,12 +1522,13 @@
       "Provamycin",
       "Rovamycine",
       "RP 5337",
-      "(4R,5S,6R,7R,9R,10R,11E,13E,16R)-10-{[(2R,5S,6R)-5-(dimethylamino)-6-methyltetrahydro-2H-pyran-2-yl]oxy}-9,16-dimethyl-5-methoxy-2-oxo-7-(2-oxoethyl)oxacyclohexadeca-11,13-dien-6-yl 3,6-dideoxy-4-O-(2,6-dideoxy-3-C-methyl-alpha-L-ribo-hexopyranosyl)-3-(dimethylamino)-alpha-D-glucopyranoside"
+      "(4R,5S,6R,7R,9R,10R,11E,13E,16R)-10-{[(2R,5S,6R)-5-(dimethylamino)-6-methyltetrahydro-2H-pyran-2-yl]oxy}-9,16-dimethyl-5-methoxy-2-oxo-7-(2-oxoethyl)oxacyclohexadeca-11,13-dien-6-yl 3,6-dideoxy-4-O-(2,6-dideoxy-3-C-methyl-alpha-L-ribo-hexopyranosyl)-3-(dimethylamino)-alpha-D-glucopyranoside",
+      "spiramycin I"
     ],
     "associated_with": [
       "umls:C0037962",
-      "fda:71ODY0V87H",
-      "chembl:CHEMBL1256397"
+      "unii:71ODY0V87H",
+      "pubchem.compound:5356392"
     ]
   },
   {
@@ -1398,7 +1543,7 @@
       "1-(3-(6,7-Dimethoxyquinazolin-4-yloxy)phenyl)-3-(5-(1,1,1-trifluoro-2-methylpropan-2-yl)isoxazol-3-yl)urea Hydrochloride"
     ],
     "xrefs": ["chemidplus:1188910-76-0"],
-    "associated_with": ["fda:78I4VEX88N"]
+    "associated_with": ["unii:78I4VEX88N"]
   },
   {
     "label_and_type": "agerafenib##label",
@@ -1435,7 +1580,7 @@
     "label_and_type": "rxcui:142424##identity",
     "concept_id": "rxcui:142424",
     "label": "amiloride hydrochloride",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "aMILoride hydrochloride",
       "Amiloride Hydrochloride",
@@ -1443,7 +1588,7 @@
     ],
     "associated_with": [
       "usp:m2650",
-      "mthspl:FZJ37245UC",
+      "unii:FZJ37245UC",
       "mmsl:2658",
       "mesh:D000584",
       "mmsl:4166",
@@ -1475,7 +1620,7 @@
     "label_and_type": "rxcui:4493##identity",
     "concept_id": "rxcui:4493",
     "label": "fluoxetine",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": [
       "FLUoxetine",
       "N-Methyl-gamma-(4-(trifluoromethyl)phenoxy)benzenepropanamine",
@@ -1490,7 +1635,7 @@
       "vandf:4019761",
       "mmsl:d00236",
       "atc:N06AB03",
-      "mthspl:01K63SUP8D"
+      "unii:01K63SUP8D"
     ],
     "trade_names": [
       "Prozac",
@@ -1511,11 +1656,11 @@
     "label_and_type": "rxcui:227224##identity",
     "concept_id": "rxcui:227224",
     "label": "fluoxetine hydrochloride",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "aliases": ["FLUoxetine hydrochloride", "Fluoxetine Hydrochloride"],
     "associated_with": [
       "usp:m33780",
-      "mthspl:I9W7N6B1KJ",
+      "unii:I9W7N6B1KJ",
       "mmsl:4746",
       "vandf:4019389",
       "mmsl:41730",
@@ -1534,7 +1679,6 @@
     "merge_ref": "wikidata:q47521576",
     "concept_id": "wikidata:Q47521576",
     "label_and_type": "wikidata:q47521576##identity",
-    "associated_with": [],
     "label": "Ethyol",
     "src_name": "Wikidata",
     "xrefs": ["rxcui:151722"]
@@ -1549,7 +1693,7 @@
     "merge_ref": "rxcui:4126",
     "concept_id": "rxcui:4126",
     "label_and_type": "rxcui:4126##identity",
-    "approval_status": "approved",
+    "approval_rating": "rxnorm_prescribable",
     "associated_with": [
       "usp:m2600",
       "mesh:D004999",
@@ -1557,7 +1701,7 @@
       "mmsl:d03868",
       "vandf:4020970",
       "mmsl:133029",
-      "mthspl:M487QF2F4V",
+      "unii:M487QF2F4V",
       "atc:V03AF05"
     ],
     "label": "amifostine",
@@ -1570,30 +1714,13 @@
     "label_and_type": "rxcui:202856##rx_brand",
     "src_name": "RxNorm"
   },
-  {
-    "merge_ref": "rxcui:2555",
-    "concept_id": "wikidata:Q47522001",
-    "label_and_type": "wikidata:q47522001##identity",
-    "associated_with": [],
-    "label": "Platinol",
-    "src_name": "Wikidata",
-    "xrefs": ["rxcui:202856"]
-  },
-  {
-    "concept_id": "wikidata:q47522001",
-    "label_and_type": "platinol##label",
-    "src_name": "Wikidata"
-  },
   {
     "label_and_type": "chemidplus:220127-57-1##identity",
     "src_name": "ChemIDplus",
     "concept_id": "chemidplus:220127-57-1",
     "label": "Imatinib mesylate",
-    "aliases": [],
     "xrefs": ["drugbank:DB00619"],
-    "associated_with": ["fda:8A1O1M485B"],
-    "approval_status": null,
-    "trade_names": []
+    "associated_with": ["unii:8A1O1M485B"]
   },
   {
     "label_and_type": "drugbank:db00619##xref",
@@ -1604,6 +1731,7 @@
     "label_and_type": "drugbank:db01174##identity",
     "src_name": "DrugBank",
     "item_type": "identity",
+    "merge_ref": "rxcui:8134",
     "concept_id": "drugbank:DB01174",
     "label": "Phenobarbital",
     "aliases": [
@@ -1624,10 +1752,7 @@
       "Phenylethylbarbitursäure",
       "Phenylethylmalonylurea"
     ],
-    "xrefs": ["chemidplus:50-06-6"],
-    "associated_with": [],
-    "approval_status": null,
-    "trade_names": []
+    "xrefs": ["chemidplus:50-06-6"]
   },
   {
     "label_and_type": "chemidplus:50-06-6##xref",
@@ -1649,10 +1774,7 @@
       "Amifostinum",
       "Aminopropylaminoethyl thiophosphate"
     ],
-    "xrefs": ["chemidplus:20537-88-6"],
-    "associated_with": [],
-    "approval_status": null,
-    "trade_names": []
+    "xrefs": ["chemidplus:20537-88-6"]
   },
   {
     "label_and_type": "chemidplus:20537-88-6##xref",
@@ -1677,11 +1799,9 @@
       "CDDP"
     ],
     "xrefs": ["rxcui:2555"],
-    "associated_with": [],
-    "trade_names": [],
-    "approval_status": "approved",
+    "approval_rating": "hemonc_approved",
     "approval_year": ["1978"],
-    "fda_indication": [
+    "has_indication": [
       ["hemonc:671", "Testicular cancer", "ncit:C7251"],
       ["hemonc:645", "Ovarian cancer", "ncit:C7431"],
       ["hemonc:569", "Bladder cancer", "ncit:C9334"]
@@ -1733,7 +1853,6 @@
       "bendamustin hydrochloride"
     ],
     "xrefs": ["rxcui:134547"],
-    "associated_with": [],
     "trade_names": [
       "Bendamax",
       "Bendawel",
@@ -1750,9 +1869,9 @@
       "Treanda",
       "Xyotin"
     ],
-    "approval_status": "approved",
+    "approval_rating": "hemonc_approved",
     "approval_year": ["2008", "2015"],
-    "fda_indication": [
+    "has_indication": [
       ["hemonc:581", "Chronic lymphocytic leukemia", "ncit:C3163"],
       ["hemonc:46094", "Indolent lymphoma", "ncit:C8504"]
     ]
@@ -1807,7 +1926,6 @@
       "bendamustin hydrochloride"
     ],
     "xrefs": ["rxcui:134547"],
-    "associated_with": [],
     "trade_names": [
       "Bendamax",
       "Bendawel",
@@ -1824,9 +1942,9 @@
       "Treanda",
       "Xyotin"
     ],
-    "approval_status": "approved",
+    "approval_rating": "hemonc_approved",
     "approval_year": ["2008", "2015"],
-    "fda_indication": [
+    "has_indication": [
       ["hemonc:581", "Chronic lymphocytic leukemia", "ncit:C3163"],
       ["hemonc:46094", "Indolent lymphoma", "ncit:C8504"]
     ]
@@ -1839,11 +1957,10 @@
     "concept_id": "hemonc:151",
     "aliases": ["degarelix acetate", "FE200486", "ASP3550"],
     "xrefs": ["rxcui:475230"],
-    "associated_with": [],
     "trade_names": ["Firmagon"],
-    "approval_status": "approved",
+    "approval_rating": "hemonc_approved",
     "approval_year": ["2008"],
-    "fda_indication": [["hemonc:658", "Prostate cancer", "ncit:C7378"]]
+    "has_indication": [["hemonc:658", "Prostate cancer", "ncit:C7378"]]
   },
   {
     "label_and_type": "rxcui:475230##xref",
@@ -1887,8 +2004,9 @@
       "TREAT",
       "Treatment",
       "TX",
-      "therapeutic intervention",
-      "treatment"
+      "treatment",
+      "treatment_or_therapy",
+      "treatment or therapy"
     ]
   },
   {
@@ -1913,6 +2031,792 @@
     "aliases": ["RO 5045337", "RG7112"],
     "associated_with": ["pubchem.compound:57406853"],
     "xrefs": ["chemidplus:939981-39-2", "chembl:CHEMBL2386346"],
-    "label": "ro-5045337"
+    "label": "ro-5045337",
+    "merge_ref": "drugbank:DB14793"
+  },
+  {
+    "label_and_type": "drugbank:db14793##merger",
+    "item_type": "merger",
+    "src_name": "DrugBank",
+    "concept_id": "drugbank:DB14793",
+    "xrefs": [
+      "iuphar.ligand:9599",
+      "chembl:CHEMBL2386346",
+      "wikidata:Q27287118"
+    ],
+    "label": "RO-5045337",
+    "aliases": [
+      "RG7112",
+      "[(4S,5R)-2-(4-tert-butyl-2-ethoxyphenyl)-4,5-bis(4-chlorophenyl)-4,5-dimethylimidazol-1-yl]-[4-(3-methylsulfonylpropyl)piperazin-1-yl]methanone",
+      "RO5045337",
+      "RG-7112",
+      "R 7112",
+      "Ro-5045337",
+      "RO 5045337"
+    ],
+    "associated_with": [
+      "pubchem.substance:340590229",
+      "pubchem.compound:57406853",
+      "unii:Q8MI0X869M",
+      "inchikey:QBGKPEROWUKSBK-QPPIDDCLSA-N"
+    ],
+    "approval_ratings": ["chembl_phase_1"]
+  },
+  {
+    "label_and_type": "ncit:c61796##identity",
+    "src_name": "NCIt",
+    "item_type": "identity",
+    "concept_id": "ncit:C61796",
+    "label": "Ivermectin",
+    "aliases": [
+      "Stromectol",
+      "IVERMECTIN",
+      "Avermectin A1a, 5-O-demethyl-25-de(1-methylpropyl)-22,23-dihydro-25-(1-methylethyl)-",
+      "Sklice"
+    ],
+    "xrefs": ["chemidplus:70288-86-7"],
+    "associated_with": ["umls:C0022322", "unii:8883YP2R6D", "CHEBI:6078"]
+  },
+  {
+    "label_and_type": "iuphar.ligand:5343##identity",
+    "src_name": "GuideToPHARMACOLOGY",
+    "item_type": "identity",
+    "concept_id": "iuphar.ligand:5343",
+    "label": "cisplatin",
+    "approval_rating": "gtopdb_approved",
+    "xrefs": [
+      "chembl:CHEMBL11359",
+      "chemidplus:15663-27-1",
+      "drugbank:DB00515"
+    ],
+    "associated_with": [
+      "pubchem.substance:178102005",
+      "pubchem.compound:441203",
+      "CHEBI:27899"
+    ],
+    "aliases": ["Platinol"],
+    "merge_ref": "rxcui:2555"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "iuphar.ligand:5343",
+    "item_type": "label",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "platinol##alias",
+    "concept_id": "iuphar.ligand:5343",
+    "item_type": "alias",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "chemidplus:15663-27-1##xref",
+    "concept_id": "iuphar.ligand:5343",
+    "item_type": "xref",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.substance:178102005##associated_with",
+    "concept_id": "iuphar.ligand:5343",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.compound:441203##associated_with",
+    "concept_id": "iuphar.ligand:5343",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "iuphar.ligand:2169##identity",
+    "src_name": "GuideToPHARMACOLOGY",
+    "item_type": "identity",
+    "concept_id": "iuphar.ligand:2169",
+    "label": "arginine vasotocin",
+    "xrefs": ["chemidplus:113-80-4"],
+    "associated_with": [
+      "pubchem.substance:135652004",
+      "pubchem.compound:68649",
+      "inchikey:OXDZADMCOWPSOC-ICBIOJHSSA-N"
+    ],
+    "aliases": [
+      "L-cysteinyl-L-tyrosyl-(3S)-DL-isoleucyl-L-glutaminyl-L-asparagyl-L-cysteinyl-DL-prolyl-L-arginyl-glycinamide (1->6)-disulfide",
+      "argiprestocin",
+      "[Arg<sup>8</sup>]vasotocin",
+      "AVT"
+    ]
+  },
+  {
+    "label_and_type": "arginine vasotocin##label",
+    "concept_id": "iuphar.ligand:2169",
+    "item_type": "label",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "avt##alias",
+    "concept_id": "iuphar.ligand:2169",
+    "item_type": "alias",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "chemidplus:113-80-4##xref",
+    "concept_id": "iuphar.ligand:2169",
+    "item_type": "xref",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.substance:135652004##associated_with",
+    "concept_id": "iuphar.ligand:2169",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.compound:68649##associated_with",
+    "concept_id": "iuphar.ligand:2169",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "iuphar.ligand:2804##identity",
+    "src_name": "GuideToPHARMACOLOGY",
+    "item_type": "identity",
+    "concept_id": "iuphar.ligand:2804",
+    "label": "phenobarbital",
+    "approval_rating": "gtopdb_approved",
+    "xrefs": ["chembl:CHEMBL40", "chemidplus:50-06-6", "drugbank:DB01174"],
+    "associated_with": [
+      "pubchem.substance:135650817",
+      "pubchem.compound:4763",
+      "CHEBI:8069",
+      "inchikey:DDBREPKUVSBGFI-UHFFFAOYSA-N",
+      "drugcentral:2134"
+    ],
+    "aliases": [
+      "5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione",
+      "fenobarbital",
+      "Luminal",
+      "phenobarb",
+      "phenobarbital sodium",
+      "phenobarbitone",
+      "phenylethylbarbiturate"
+    ],
+    "merge_ref": "rxcui:8134"
+  },
+  {
+    "label_and_type": "phenobarbital##label",
+    "concept_id": "iuphar.ligand:2804",
+    "item_type": "label",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione##alias",
+    "concept_id": "iuphar.ligand:2804",
+    "item_type": "alias",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "phenobarb##alias",
+    "concept_id": "iuphar.ligand:2804",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "phenobarbitone##alias",
+    "concept_id": "iuphar.ligand:2804",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "chembl:chembl40##xref",
+    "concept_id": "iuphar.ligand:2804",
+    "item_type": "xref",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "chemidplus:50-06-6##xref",
+    "concept_id": "iuphar.ligand:2804",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "drugbank:db01174##xref",
+    "concept_id": "iuphar.ligand:2804",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.substance:135650817##associated_with",
+    "concept_id": "iuphar.ligand:2804",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.compound:4763##associated_with",
+    "concept_id": "iuphar.ligand:2804",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "drugcentral:2134##associated_with",
+    "concept_id": "iuphar.ligand:2804",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "iuphar.ligand:240##identity",
+    "src_name": "GuideToPHARMACOLOGY",
+    "item_type": "identity",
+    "concept_id": "iuphar.ligand:240",
+    "label": "cisapride",
+    "approval_rating": "gtopdb_withdrawn",
+    "xrefs": ["chembl:CHEMBL1729", "chemidplus:81098-60-4", "drugbank:DB00604"],
+    "associated_with": [
+      "pubchem.substance:135650104",
+      "pubchem.compound:2769",
+      "CHEBI:151790",
+      "inchikey:DCSUBABJRXZOMT-UHFFFAOYSA-N",
+      "drugcentral:660"
+    ],
+    "aliases": [
+      "4-amino-5-chloro-N-[1-[3-(4-fluorophenoxy)propyl]-3-methoxypiperidin-4-yl]-2-methoxybenzamide",
+      "Prepulsid",
+      "Propulsid"
+    ]
+  },
+  {
+    "label_and_type": "cisapride##label",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "label",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "prepulsid##alias",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "alias",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "drugbank:db00604##xref",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "xref",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "chembl:chembl1729##xref",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "xref",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "chemidplus:81098-60-4##xref",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "xref",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.substance:135650104##associated_with",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "pubchem.compound:2769##associated_with",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "inchikey:dcsubabjrxzomt-uhfffaoysa-n##associated_with",
+    "concept_id": "iuphar.ligand:240",
+    "item_type": "associated_with",
+    "src_name": "GuideToPHARMACOLOGY"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:075036##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "merge_ref": "rxcui:2555",
+    "label": "CISPLATIN",
+    "concept_id": "drugsatfda.anda:075036",
+    "xrefs": ["rxcui:309311"],
+    "associated_with": [
+      "unii:Q20Q21Q62J",
+      "ndc:0143-9504",
+      "ndc:0143-9505",
+      "spl:2c569ef0-588f-4828-8b2d-03a2120c9b4c"
+    ],
+    "approval_rating": "fda_prescription"
+  },
+  {
+    "label_and_type": "rxcui:309311##xref",
+    "concept_id": "drugsatfda.anda:075036",
+    "item_type": "xref",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "drugsatfda.anda:075036",
+    "item_type": "label",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "unii:q20q21q62j##associated_with",
+    "concept_id": "drugsatfda.anda:075036",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:206774##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "merge_ref": "rxcui:2555",
+    "label": "CISPLATIN",
+    "concept_id": "drugsatfda.anda:206774",
+    "xrefs": ["rxcui:309311"],
+    "associated_with": [
+      "unii:Q20Q21Q62J",
+      "ndc:16729-288",
+      "ndc:68001-283",
+      "spl:c3ddc4a5-9f1b-a8ee-e053-2a95a90a2265",
+      "spl:c43de769-d6d8-3bb9-e053-2995a90a5aa2"
+    ],
+    "approval_rating": "fda_prescription"
+  },
+  {
+    "label_and_type": "rxcui:309311##xref",
+    "concept_id": "drugsatfda.anda:206774",
+    "item_type": "xref",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "drugsatfda.anda:206774",
+    "item_type": "label",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "unii:q20q21q62j##associated_with",
+    "concept_id": "drugsatfda.anda:206774",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:207323##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "merge_ref": "rxcui:2555",
+    "label": "CISPLATIN",
+    "concept_id": "drugsatfda.anda:207323",
+    "xrefs": ["rxcui:309311"],
+    "associated_with": [
+      "unii:Q20Q21Q62J",
+      "ndc:68083-162",
+      "ndc:68083-163",
+      "ndc:70860-206",
+      "spl:01c7a680-ee0d-42da-85e8-8d56c6fe7006",
+      "spl:5a24d5bd-c44a-43f7-a04c-76caf3475012"
+    ],
+    "approval_rating": "fda_prescription"
+  },
+  {
+    "label_and_type": "rxcui:309311##xref",
+    "concept_id": "drugsatfda.anda:207323",
+    "item_type": "xref",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "drugsatfda.anda:207323",
+    "item_type": "label",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "unii:q20q21q62j##associated_with",
+    "concept_id": "drugsatfda.anda:207323",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:074735##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "merge_ref": "rxcui:2555",
+    "label": "CISPLATIN",
+    "concept_id": "drugsatfda.anda:074735",
+    "xrefs": ["rxcui:309311"],
+    "associated_with": [
+      "unii:Q20Q21Q62J",
+      "ndc:63323-103",
+      "spl:9b008181-ab66-db2f-e053-2995a90aad57"
+    ],
+    "approval_rating": "fda_prescription"
+  },
+  {
+    "label_and_type": "rxcui:309311##xref",
+    "concept_id": "drugsatfda.anda:074735",
+    "item_type": "xref",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "drugsatfda.anda:074735",
+    "item_type": "label",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "unii:q20q21q62j##associated_with",
+    "concept_id": "drugsatfda.anda:074735",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:074656##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "merge_ref": "rxcui:2555",
+    "label": "CISPLATIN",
+    "concept_id": "drugsatfda.anda:074656",
+    "xrefs": ["rxcui:309311"],
+    "associated_with": [
+      "unii:Q20Q21Q62J",
+      "ndc:0703-5747",
+      "ndc:0703-5748",
+      "spl:54b3415c-c095-4c82-b216-e0e6e6bb8d03"
+    ],
+    "approval_rating": "fda_prescription"
+  },
+  {
+    "label_and_type": "unii:q20q21q62j##associated_with",
+    "concept_id": "drugsatfda.anda:074656",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "rxcui:309311##xref",
+    "concept_id": "drugsatfda.anda:074656",
+    "item_type": "xref",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "drugsatfda.anda:074656",
+    "item_type": "label",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:018057##identity",
+    "src_name": "DrugsAtFDA",
+    "item_type": "identity",
+    "label": "CISPLATIN",
+    "concept_id": "drugsatfda.nda:018057",
+    "xrefs": ["rxcui:1736854", "rxcui:309311"],
+    "associated_with": [
+      "unii:Q20Q21Q62J",
+      "ndc:44567-509",
+      "ndc:44567-510",
+      "ndc:44567-511",
+      "ndc:44567-530",
+      "spl:a66eda32-1164-439a-ac8e-73138365ec06",
+      "spl:dd45d777-d4c1-40ee-b4f0-c9e001a15a8c"
+    ],
+    "trade_names": ["PLATINOL-AQ", "PLATINOL"]
+  },
+  {
+    "label_and_type": "rxcui:309311##xref",
+    "concept_id": "drugsatfda.nda:018057",
+    "item_type": "xref",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "cisplatin##label",
+    "concept_id": "drugsatfda.nda:018057",
+    "item_type": "label",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "unii:q20q21q62j##associated_with",
+    "concept_id": "drugsatfda.nda:018057",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:210595##identity",
+    "src_name": "DrugsAtFDA",
+    "item_type": "identity",
+    "concept_id": "drugsatfda.nda:210595",
+    "aliases": [
+      "ACLIDINIUM BROMIDE",
+      "ACLIDINIUM BROMIDE AND FORMOTEROL FUMARTE",
+      "FORMOTEROL FUMARATE"
+    ],
+    "xrefs": [
+      "rxcui:2205103",
+      "rxcui:2205104",
+      "rxcui:2205099",
+      "rxcui:2205094"
+    ],
+    "associated_with": ["unii:UQW7UF9N91", "unii:W34SHF8J2K"],
+    "approval_rating": "fda_prescription",
+    "trade_names": ["DUAKLIR PRESSAIR"]
+  },
+  {
+    "label_and_type": "unii:w34shf8j2k##associated_with",
+    "concept_id": "drugsatfda.nda:210595",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "unii:uqw7uf9n91##associated_with",
+    "concept_id": "drugsatfda.nda:210595",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:202450##identity",
+    "src_name": "DrugsAtFDA",
+    "item_type": "identity",
+    "concept_id": "drugsatfda.nda:202450",
+    "label": "ACLIDINIUM BROMIDE",
+    "xrefs": [
+      "rxcui:1607112",
+      "rxcui:1303102",
+      "rxcui:1607111",
+      "rxcui:1303107"
+    ],
+    "associated_with": ["unii:UQW7UF9N91"],
+    "approval_rating": "fda_prescription",
+    "trade_names": ["TUDORZA PRESSAIR"]
+  },
+  {
+    "label_and_type": "unii:uqw7uf9n91##associated_with",
+    "concept_id": "drugsatfda.nda:202450",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:091141##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "concept_id": "drugsatfda.anda:091141",
+    "label": "FORMOTEROL FUMARATE",
+    "xrefs": ["rxcui:1246319"],
+    "associated_with": ["unii:W34SHF8J2K"],
+    "approval_rating": "fda_prescription"
+  },
+  {
+    "label_and_type": "unii:w34shf8j2k##associated_with",
+    "concept_id": "drugsatfda.anda:091141",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:022007##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "concept_id": "drugsatfda.nda:022007",
+    "label": "FORMOTEROL FUMARATE",
+    "aliases": ["FORMOTEROL FUMARATE DIHYDRATE"],
+    "xrefs": ["rxcui:1246321", "rxcui:1246319"],
+    "associated_with": ["unii:W34SHF8J2K"],
+    "approval_rating": "fda_prescription",
+    "trade_names": ["PERFOROMIST"]
+  },
+  {
+    "label_and_type": "unii:w34shf8j2k##associated_with",
+    "concept_id": "drugsatfda.nda:022007",
+    "item_type": "associated_with",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:022334##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "label": "EVEROLIMUS",
+    "concept_id": "drugsatfda.nda:022334",
+    "xrefs": [
+      "rxcui:998191",
+      "rxcui:1310144",
+      "rxcui:1310147",
+      "rxcui:845507",
+      "rxcui:845518",
+      "rxcui:845515",
+      "rxcui:1310138",
+      "rxcui:845512",
+      "rxcui:998189",
+      "rxcui:1308430",
+      "rxcui:1119402",
+      "rxcui:1308428",
+      "rxcui:1119400",
+      "rxcui:1308432"
+    ],
+    "associated_with": [
+      "unii:9HW64Q8G6G",
+      "ndc:0078-0566",
+      "ndc:0078-0594",
+      "ndc:0078-0566",
+      "spl:8ad662ec-6e9b-4650-9f5a-78dda1f9f8f0",
+      "ndc:0078-0567",
+      "ndc:0078-0620",
+      "ndc:0078-0628",
+      "ndc:0078-0626",
+      "ndc:0078-0627"
+    ],
+    "approval_rating": "fda_prescription",
+    "trade_names": ["AFINITOR"]
+  },
+  {
+    "label_and_type": "rxcui:998191##xref",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "xref"
+  },
+  {
+    "label_and_type": "rxcui:845507##xref",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "xref"
+  },
+  {
+    "label_and_type": "rxcui:998189##xref",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "xref"
+  },
+  {
+    "label_and_type": "rxcui:1308432##xref",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "xref"
+  },
+  {
+    "label_and_type": "unii:9hw64q8g6g##associated_with",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "associated_with"
+  },
+  {
+    "label_and_type": "afinitor##trade_name",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "trade_name"
+  },
+  {
+    "label_and_type": "everolimus##label",
+    "concept_id": "drugsatfda.nda:022334",
+    "src_name": "DrugsAtFDA",
+    "item_type": "label"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:050682##identity",
+    "src_name": "DrugsAtFDA",
+    "item_type": "identity",
+    "label": "DACTINOMYCIN",
+    "concept_id": "drugsatfda.nda:050682",
+    "xrefs": ["rxcui:105569", "rxcui:239179"],
+    "associated_with": [
+      "unii:1CC1JFE158",
+      "ndc:55292-811",
+      "ndc:66993-489",
+      "spl:137c07d7-e2bf-4f59-8b8f-4ebe119e576d",
+      "spl:b92f5536-179c-6234-e053-2995a90a0f25"
+    ],
+    "approval_rating": "fda_prescription",
+    "trade_names": ["COSMEGEN"]
+  },
+  {
+    "label_and_type": "unii:1cc1jfe158##associated_with",
+    "src_name": "DrugsAtFDA",
+    "item_type": "associated_with",
+    "concept_id": "drugsatfda.nda:050682"
+  },
+  {
+    "label_and_type": "rxcui:239179##xref",
+    "src_name": "DrugsAtFDA",
+    "item_type": "xref",
+    "concept_id": "drugsatfda.nda:050682"
+  },
+  {
+    "label_and_type": "rxcui:105569##xref",
+    "src_name": "DrugsAtFDA",
+    "item_type": "xref",
+    "concept_id": "drugsatfda.nda:050682"
+  },
+  {
+    "label_and_type": "cosmegen##trade_name",
+    "src_name": "DrugsAtFDA",
+    "item_type": "trade_name",
+    "concept_id": "drugsatfda.nda:050682"
+  },
+  {
+    "label_and_type": "dactinomycin##label",
+    "src_name": "DrugsAtFDA",
+    "item_type": "label",
+    "concept_id": "drugsatfda.nda:050682"
+  },
+  {
+    "label_and_type": "drugsatfda.anda:072267##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "label": "FENOPROFEN CALCIUM",
+    "concept_id": "drugsatfda.anda:072267",
+    "xrefs": ["rxcui:310291", "rxcui:351398"],
+    "associated_with": [
+      "unii:0X2CW1QABJ",
+      "ndc:42195-471",
+      "ndc:42195-688",
+      "spl:c1bfb82a-814b-23f3-e053-2995a90ae9cf",
+      "spl:c1bfb82a-8192-23f3-e053-2995a90ae9cf"
+    ],
+    "approval_rating": "fda_prescription",
+    "trade_names": ["NALFON"]
+  },
+  {
+    "label_and_type": "unii:0x2cw1qabj##associated_with",
+    "item_type": "associated_with",
+    "concept_id": "drugsatfda.anda:072267",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "fenoprofen calcium##label",
+    "item_type": "label",
+    "concept_id": "drugsatfda.anda:072267",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "rxcui:310291##xref",
+    "item_type": "xref",
+    "concept_id": "drugsatfda.anda:072267",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "drugsatfda.nda:017604##identity",
+    "item_type": "identity",
+    "src_name": "DrugsAtFDA",
+    "label": "FENOPROFEN CALCIUM",
+    "concept_id": "drugsatfda.nda:017604",
+    "associated_with": [
+      "unii:0X2CW1QABJ",
+      "ndc:15014-400",
+      "ndc:42195-100",
+      "ndc:42195-308",
+      "ndc:42195-600",
+      "ndc:69336-113",
+      "ndc:69336-124",
+      "spl:81a0da5b-6078-413e-b6d0-d31c1073b3f7",
+      "spl:bedf1661-b811-3d85-e053-2995a90acd59",
+      "spl:c1721025-8af3-5451-e053-2a95a90a7dc0",
+      "spl:c1829550-d270-e13d-e053-2995a90a4aa5",
+      "spl:c187dfec-eec5-4227-e053-2995a90ab55c"
+    ],
+    "xrefs": ["rxcui:197694", "rxcui:858118", "rxcui:858116", "rxcui:260323"],
+    "trade_names": ["NALFON", "FENORTHO"]
+  },
+  {
+    "label_and_type": "unii:0x2cw1qabj##associated_with",
+    "item_type": "associated_with",
+    "concept_id": "drugsatfda.nda:017604",
+    "src_name": "DrugsAtFDA"
+  },
+  {
+    "label_and_type": "fenoprofen calcium##label",
+    "item_type": "label",
+    "concept_id": "drugsatfda.nda:017604",
+    "src_name": "DrugsAtFDA"
   }
 ]
diff --git a/tests/unit/dynamodb_build.bash b/tests/unit/dynamodb_build.bash
deleted file mode 100644
index a476be6f..00000000
--- a/tests/unit/dynamodb_build.bash
+++ /dev/null
@@ -1,6 +0,0 @@
-#! /bin/bash
-mkdir ./tests/unit/dynamodb_local
-cd ./tests/unit/dynamodb_local
-curl -O "https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.tar.gz"
-tar -xvzf dynamodb_local_latest.tar.gz; rm dynamodb_local_latest.tar.gz
-java -jar DynamoDBLocal.jar &
diff --git a/tests/unit/test_chembl.py b/tests/unit/test_chembl.py
new file mode 100644
index 00000000..daed638d
--- /dev/null
+++ b/tests/unit/test_chembl.py
@@ -0,0 +1,277 @@
+"""Test that the therapy normalizer works as intended for the ChEMBL source."""
+import re
+
+import pytest
+
+from therapy.schemas import Drug, MatchType
+from therapy.query import QueryHandler
+from tests.conftest import compare_records
+
+
+@pytest.fixture(scope="module")
+def chembl():
+    """Build ChEMBL normalizer test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.query_handler = QueryHandler()
+
+        def search(self, query_str):
+            resp = self.query_handler.search_sources(query_str, keyed=True,
+                                                     incl="chembl")
+            return resp["source_matches"]["ChEMBL"]
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def cisplatin():
+    """Create a cisplatin drug fixture."""
+    params = {
+        "label": "CISPLATIN",
+        "concept_id": "chembl:CHEMBL11359",
+        "aliases": [
+            "Cisplatin",
+            "Cis-platinum ii",
+            "Cisplatinum",
+            "cis-diamminedichloroplatinum(II)",
+            "CIS-DDP",
+            "INT-230-6 COMPONENT CISPLATIN",
+            "INT230-6 COMPONENT CISPLATIN",
+            "Liplacis",
+            "NSC-119875",
+            "Platinol",
+            "Platinol-aq",
+        ],
+        "approval_rating": "chembl_phase_4",
+        "xrefs": [],
+        "associated_with": [],
+        "trade_names": [
+            "PLATINOL",
+            "PLATINOL-AQ"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def l745870():
+    """Create a L-745870 drug fixture."""
+    params = {
+        "label": "L-745870",
+        "concept_id": "chembl:CHEMBL267014",
+        "aliases": [],
+        "approval_rating": "chembl_phase_0",
+        "xrefs": [],
+        "associated_with": [],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+# Test with aliases and trade names > 20
+@pytest.fixture(scope="module")
+def aspirin():
+    """Create an aspirin drug fixture."""
+    params = {
+        "label": "ASPIRIN",
+        "concept_id": "chembl:CHEMBL25",
+        "aliases": [],
+        "approval_rating": "chembl_phase_4",
+        "xrefs": [],
+        "associated_with": [],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+def test_concept_id_cisplatin(cisplatin, chembl):
+    """Test that cisplatin drug normalizes to correct drug concept
+    as a CONCEPT_ID match.
+    """
+    response = chembl.search("chembl:CHEMBL11359")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chembl.search("CHEMBL11359")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chembl.search("chembl:chembl11359")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chembl.search("cHEmbl:chembl11359")
+    assert response["match_type"] == \
+           MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chembl.search("cHEmbl:CHEMBL11359")
+    assert response["match_type"] == \
+           MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+
+def test_cisplatin_label(cisplatin, chembl):
+    """Test that cisplatin drug normalizes to correct drug concept
+    as a LABEL match.
+    """
+    response = chembl.search("CISPLATIN")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 2
+    if len(response["records"][0]["aliases"]) > len(response["records"][1]["aliases"]):
+        ind = 0
+    else:
+        ind = 1
+    compare_records(response["records"][ind], cisplatin)
+
+    response = chembl.search("cisplatin")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 2
+    if len(response["records"][0]["aliases"]) > len(response["records"][1]["aliases"]):
+        ind = 0
+    else:
+        ind = 1
+    compare_records(response["records"][ind], cisplatin)
+
+
+def test_cisplatin_alias(cisplatin, chembl):
+    """Test that alias term normalizes to correct drug concept as an
+    ALIAS match.
+    """
+    response = chembl.search("cis-diamminedichloroplatinum(II)")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chembl.search("INT230-6 COMPONENT CISPLATIn")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+
+def test_no_match(chembl):
+    """Test that a term normalizes to correct drug concept as a NO match."""
+    response = chembl.search("cisplati")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+    # Test white space in between label
+    response = chembl.search("L - 745870")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Test empty query
+    response = chembl.search("")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+
+def test_l745870_concept_id(l745870, chembl):
+    """Test that L-745870 drug normalizes to correct drug concept
+    as a CONCEPT_ID match.
+    """
+    response = chembl.search("chembl:CHEMBL267014")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+    response = chembl.search("CHEMBL267014")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+    response = chembl.search("chembl:chembl267014")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+    response = chembl.search("cHEmbl:chembl267014")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+    response = chembl.search("cHEmbl:CHEMBL267014")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+
+def test_l745870_label(l745870, chembl):
+    """Test that L-745870 drug normalizes to correct drug concept
+    as a LABEL match.
+    """
+    response = chembl.search("L-745870")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+    response = chembl.search("l-745870")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], l745870)
+
+
+def test_aspirin_concept_id(aspirin, chembl):
+    """Test that L-745870 drug normalizes to correct drug concept
+    as a CONCEPT_ID match.
+    """
+    response = chembl.search("chembl:CHEMBL25")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+    response = chembl.search("CHEMBL25")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+    response = chembl.search("chembl:chembl25")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+    response = chembl.search("cHEmbl:chembl25")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+    response = chembl.search("cHEmbl:CHEMBL25")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+
+def test_aspirin_label(aspirin, chembl):
+    """Test that L-745870 drug normalizes to correct drug concept
+    as a LABEL match.
+    """
+    response = chembl.search("ASPIRIN")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+    response = chembl.search("aspirin")
+    assert response["match_type"] ==\
+           MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+
+def test_meta_info(chembl):
+    """Test that the meta field is correct."""
+    response = chembl.search("cisplatin")
+    assert response["source_meta_"]["data_license"] == "CC BY-SA 3.0"
+    assert response["source_meta_"]["data_license_url"] == \
+           "https://creativecommons.org/licenses/by-sa/3.0/"
+    assert re.match(r"[0-3][0-9]", response["source_meta_"]["version"])
+    assert response["source_meta_"]["data_url"].startswith("ftp://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb")  # noqa: E501
+    assert response["source_meta_"]["rdp_url"] == "http://reusabledata.org/chembl.html"
+    assert response["source_meta_"]["data_license_attributes"] == {
+        "non_commercial": False,
+        "share_alike": True,
+        "attribution": True
+    }
diff --git a/tests/unit/test_chembl_source.py b/tests/unit/test_chembl_source.py
deleted file mode 100644
index c66a9ac7..00000000
--- a/tests/unit/test_chembl_source.py
+++ /dev/null
@@ -1,276 +0,0 @@
-"""Test that the therapy normalizer works as intended for the ChEMBL source."""
-import pytest
-from therapy.schemas import Drug, MatchType
-from therapy.query import QueryHandler
-from tests.conftest import compare_records
-
-
-@pytest.fixture(scope='module')
-def chembl():
-    """Build ChEMBL normalizer test fixture."""
-    class QueryGetter:
-
-        def __init__(self):
-            self.query_handler = QueryHandler()
-
-        def search(self, query_str):
-            resp = self.query_handler.search_sources(query_str, keyed=True,
-                                                     incl='chembl')
-            return resp['source_matches']['ChEMBL']
-    return QueryGetter()
-
-
-@pytest.fixture(scope='module')
-def cisplatin():
-    """Create a cisplatin drug fixture."""
-    params = {
-        'label': 'CISPLATIN',
-        'concept_id': 'chembl:CHEMBL11359',
-        'aliases': [
-            'Cisplatin',
-            'Cis-Platinum II',
-            'Cisplatinum',
-            'cis-diamminedichloroplatinum(II)',
-            'CIS-DDP',
-            'INT-230-6 COMPONENT CISPLATIN',
-            'INT230-6 COMPONENT CISPLATIN',
-            'NSC-119875',
-            'Platinol',
-            'Platinol-Aq'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [],
-        'trade_names': [
-            'PLATINOL',
-            'PLATINOL-AQ'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def l745870():
-    """Create a L-745870 drug fixture."""
-    params = {
-        'label': 'L-745870',
-        'concept_id': 'chembl:CHEMBL267014',
-        'aliases': [],
-        'approval_status': None,
-        'xrefs': [],
-        'associated_with': [],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-# Test with aliases and trade names > 20
-@pytest.fixture(scope='module')
-def aspirin():
-    """Create an aspirin drug fixture."""
-    params = {
-        'label': 'ASPIRIN',
-        'concept_id': 'chembl:CHEMBL25',
-        'aliases': [],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-def test_concept_id_cisplatin(cisplatin, chembl):
-    """Test that cisplatin drug normalizes to correct drug concept
-    as a CONCEPT_ID match.
-    """
-    response = chembl.search('chembl:CHEMBL11359')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chembl.search('CHEMBL11359')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chembl.search('chembl:chembl11359')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chembl.search('cHEmbl:chembl11359')
-    assert response['match_type'] == \
-           MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chembl.search('cHEmbl:CHEMBL11359')
-    assert response['match_type'] == \
-           MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-
-def test_cisplatin_label(cisplatin, chembl):
-    """Test that cisplatin drug normalizes to correct drug concept
-    as a LABEL match.
-    """
-    response = chembl.search('CISPLATIN')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 2
-    if len(response['records'][0]['aliases']) >\
-            len(response['records'][1]['aliases']):
-        ind = 0
-    else:
-        ind = 1
-    compare_records(response['records'][ind], cisplatin)
-
-    response = chembl.search('cisplatin')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 2
-    if len(response['records'][0]['aliases']) >\
-            len(response['records'][1]['aliases']):
-        ind = 0
-    else:
-        ind = 1
-    compare_records(response['records'][ind], cisplatin)
-
-
-def test_cisplatin_alias(cisplatin, chembl):
-    """Test that alias term normalizes to correct drug concept as an
-    ALIAS match.
-    """
-    response = chembl.search('cis-diamminedichloroplatinum(II)')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chembl.search('INT230-6 COMPONENT CISPLATIn')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-
-def test_no_match(chembl):
-    """Test that a term normalizes to correct drug concept as a NO match."""
-    response = chembl.search('cisplati')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-    # Test white space in between label
-    response = chembl.search('L - 745870')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    # Test empty query
-    response = chembl.search('')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-
-def test_l745870_concept_id(l745870, chembl):
-    """Test that L-745870 drug normalizes to correct drug concept
-    as a CONCEPT_ID match.
-    """
-    response = chembl.search('chembl:CHEMBL267014')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-    response = chembl.search('CHEMBL267014')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-    response = chembl.search('chembl:chembl267014')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-    response = chembl.search('cHEmbl:chembl267014')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-    response = chembl.search('cHEmbl:CHEMBL267014')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-
-def test_l745870_label(l745870, chembl):
-    """Test that L-745870 drug normalizes to correct drug concept
-    as a LABEL match.
-    """
-    response = chembl.search('L-745870')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-    response = chembl.search('l-745870')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], l745870)
-
-
-def test_aspirin_concept_id(aspirin, chembl):
-    """Test that L-745870 drug normalizes to correct drug concept
-    as a CONCEPT_ID match.
-    """
-    response = chembl.search('chembl:CHEMBL25')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-    response = chembl.search('CHEMBL25')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-    response = chembl.search('chembl:chembl25')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-    response = chembl.search('cHEmbl:chembl25')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-    response = chembl.search('cHEmbl:CHEMBL25')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-
-def test_aspirin_label(aspirin, chembl):
-    """Test that L-745870 drug normalizes to correct drug concept
-    as a LABEL match.
-    """
-    response = chembl.search('ASPIRIN')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-    response = chembl.search('aspirin')
-    assert response['match_type'] ==\
-           MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], aspirin)
-
-
-def test_meta_info(cisplatin, chembl):
-    """Test that the meta field is correct."""
-    response = chembl.search('cisplatin')
-    assert response['source_meta_']['data_license'] == 'CC BY-SA 3.0'
-    assert response['source_meta_']['data_license_url'] == \
-           'https://creativecommons.org/licenses/by-sa/3.0/'
-    assert response['source_meta_']['version'] == '27'
-    assert response['source_meta_']['data_url'] == \
-           'http://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases/chembl_27/'  # noqa: E501
-    assert response['source_meta_']['rdp_url'] == 'http://reusabledata.org/chembl.html'  # noqa: E501
-    assert response['source_meta_']['data_license_attributes'] == {
-        "non_commercial": False,
-        "share_alike": True,
-        "attribution": True
-    }
diff --git a/tests/unit/test_chemidplus.py b/tests/unit/test_chemidplus.py
new file mode 100644
index 00000000..e76dc52b
--- /dev/null
+++ b/tests/unit/test_chemidplus.py
@@ -0,0 +1,200 @@
+"""Test ChemIDplus ETL methods."""
+import pytest
+import isodate
+
+from therapy.query import QueryHandler
+from therapy.schemas import Drug, MatchType
+from tests.conftest import compare_records
+
+
+@pytest.fixture(scope="module")
+def chemidplus():
+    """Build ETL test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.normalizer = QueryHandler()
+
+        def search(self, query_str):
+            resp = self.normalizer.search_sources(query_str, keyed=True,
+                                                  incl="chemidplus")
+            return resp["source_matches"]["ChemIDplus"]
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def penicillin_v():
+    """Build test fixture for Penicillin V drug."""
+    return Drug(**{
+        "concept_id": "chemidplus:87-08-1",
+        "label": "Penicillin V",
+        "aliases": [
+            "Phenoxymethylpenicillin"
+        ],
+        "trade_names": [],
+        "xrefs": [
+            "drugbank:DB00417",
+        ],
+        "associated_with": [
+            "unii:Z61I075U2W",
+        ]
+    })
+
+
+@pytest.fixture(scope="module")
+def imatinib():
+    """Build test fixture for Imatinib."""
+    return Drug(**{
+        "concept_id": "chemidplus:152459-95-5",
+        "label": "Imatinib",
+        "aliases": [],
+        "trade_names": [],
+        "xrefs": [
+            "drugbank:DB00619",
+        ],
+        "associated_with": [
+            "unii:BKJ8M8G5HI",
+        ]
+    })
+
+
+@pytest.fixture(scope="module")
+def other_imatinib():
+    """Build test fixture for imatinib mesylate."""
+    return Drug(**{
+        "concept_id": "chemidplus:220127-57-1",
+        "label": "Imatinib mesylate",
+        "aliases": [],
+        "xrefs": ["drugbank:DB00619"],
+        "associated_with": ["unii:8A1O1M485B"],
+        "trade_names": [],
+    })
+
+
+@pytest.fixture(scope="module")
+def cisplatin():
+    """Build test fixture for cisplatin."""
+    return Drug(**{
+        "concept_id": "chemidplus:15663-27-1",
+        "label": "Cisplatin",
+        "aliases": [
+            "cis-Diaminedichloroplatinum",
+            "1,2-Diaminocyclohexaneplatinum II citrate"
+        ],
+        "trade_names": [],
+        "xrefs": [
+            "drugbank:DB00515"
+        ],
+        "associated_with": [
+            "unii:Q20Q21Q62J"
+        ]
+    })
+
+
+def test_penicillin(chemidplus, penicillin_v):
+    """Test record retrieval for Penicillin V."""
+    response = chemidplus.search("chemidplus:87-08-1")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], penicillin_v)
+
+    response = chemidplus.search("CHemidplus:87-08-1")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], penicillin_v)
+
+    response = chemidplus.search("Penicillin V")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], penicillin_v)
+
+    response = chemidplus.search("Phenoxymethylpenicillin")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], penicillin_v)
+
+    response = chemidplus.search("drugbank:DB00417")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], penicillin_v)
+
+    response = chemidplus.search("unii:Z61I075U2W")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], penicillin_v)
+
+    response = chemidplus.search("87081")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    response = chemidplus.search("PenicillinV")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+
+def test_imatinib(chemidplus, imatinib, other_imatinib):
+    """Test record retrieval for imatinib."""
+    response = chemidplus.search("Imatinib")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], imatinib)
+
+    response = chemidplus.search("imatiniB")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], imatinib)
+
+    response = chemidplus.search("drugbank:DB00619")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 2
+    for record in response["records"]:
+        if record["concept_id"] == "chemidplus:152459-95-5":
+            compare_records(record, imatinib)
+        elif record["concept_id"] == "chemidplus:220127-57-1":
+            compare_records(record, other_imatinib)
+        else:
+            assert False  # retrieved incorrect record
+
+    response = chemidplus.search("unii:BKJ8M8G5HI")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], imatinib)
+
+
+def test_cisplatin(chemidplus, cisplatin):
+    """Test record retrieval for cisplatin."""
+    response = chemidplus.search("chemidplus:15663-27-1")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chemidplus.search("Cisplatin")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chemidplus.search("cis-Diaminedichloroplatinum")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chemidplus.search("drugbank:DB00515")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = chemidplus.search("Cisplatine")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+
+def test_meta(chemidplus):
+    """Test correctness of source metadata."""
+    response = chemidplus.search("incoherent-string-of-text")
+    assert response["source_meta_"]["data_license"] == "custom"
+    assert response["source_meta_"]["data_license_url"] == "https://www.nlm.nih.gov/databases/download/terms_and_conditions.html"  # noqa: E501
+    assert isodate.parse_date(response["source_meta_"]["version"])
+    assert response["source_meta_"]["data_url"] == "ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/"  # noqa: E501
+    assert response["source_meta_"]["rdp_url"] is None
+    assert response["source_meta_"]["data_license_attributes"] == {
+        "non_commercial": False,
+        "share_alike": False,
+        "attribution": True
+    }
diff --git a/tests/unit/test_chemidplus_source.py b/tests/unit/test_chemidplus_source.py
deleted file mode 100644
index 3e705a4d..00000000
--- a/tests/unit/test_chemidplus_source.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""Test ChemIDplus ETL methods."""
-from therapy.query import QueryHandler
-from therapy.schemas import Drug, MatchType
-import pytest
-from tests.conftest import compare_records
-import datetime
-
-
-@pytest.fixture(scope='module')
-def chemidplus():
-    """Build ETL test fixture."""
-    class QueryGetter:
-
-        def __init__(self):
-            self.normalizer = QueryHandler()
-
-        def search(self, query_str):
-            resp = self.normalizer.search_sources(query_str, keyed=True,
-                                                  incl='chemidplus')
-            return resp['source_matches']['ChemIDplus']
-    return QueryGetter()
-
-
-@pytest.fixture(scope='module')
-def penicillin_v():
-    """Build test fixture for Penicillin V drug."""
-    return Drug(**{
-        "concept_id": "chemidplus:87-08-1",
-        "label": "Penicillin V",
-        "aliases": [
-            'Phenoxymethylpenicillin'
-        ],
-        "trade_names": [],
-        "xrefs": [
-            "drugbank:DB00417",
-        ],
-        "associated_with": [
-            "fda:Z61I075U2W",
-        ]
-    })
-
-
-@pytest.fixture(scope='module')
-def imatinib():
-    """Build test fixture for Imatinib."""
-    return Drug(**{
-        "concept_id": "chemidplus:152459-95-5",
-        "label": "Imatinib",
-        "aliases": [],
-        "trade_names": [],
-        "xrefs": [
-            "drugbank:DB00619",
-        ],
-        "associated_with": [
-            "fda:BKJ8M8G5HI",
-        ]
-    })
-
-
-@pytest.fixture(scope='module')
-def other_imatinib():
-    """Build test fixture for imatinib mesylate."""
-    return Drug(**{
-        "concept_id": "chemidplus:220127-57-1",
-        "label": "Imatinib mesylate",
-        "aliases": [],
-        "xrefs": ["drugbank:DB00619"],
-        "associated_with": ["fda:8A1O1M485B"],
-        "trade_names": [],
-    })
-
-
-@pytest.fixture(scope='module')
-def cisplatin():
-    """Build test fixture for cisplatin."""
-    return Drug(**{
-        'concept_id': 'chemidplus:15663-27-1',
-        'label': 'Cisplatin',
-        'aliases': [
-            'cis-Diaminedichloroplatinum',
-            '1,2-Diaminocyclohexaneplatinum II citrate'
-        ],
-        'trade_names': [],
-        'xrefs': [
-            'drugbank:DB00515'
-        ],
-        "associated_with": [
-            'fda:Q20Q21Q62J'
-        ]
-    })
-
-
-def test_penicillin(chemidplus, penicillin_v):
-    """Test record retrieval for Penicillin V."""
-    response = chemidplus.search('chemidplus:87-08-1')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], penicillin_v)
-
-    response = chemidplus.search('CHemidplus:87-08-1')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], penicillin_v)
-
-    response = chemidplus.search('Penicillin V')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], penicillin_v)
-
-    response = chemidplus.search('Phenoxymethylpenicillin')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], penicillin_v)
-
-    response = chemidplus.search('drugbank:DB00417')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], penicillin_v)
-
-    response = chemidplus.search('fda:Z61I075U2W')
-    assert response['match_type'] == MatchType.ASSOCIATED_WITH
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], penicillin_v)
-
-    response = chemidplus.search('87-08-1')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    response = chemidplus.search('87081')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    response = chemidplus.search('PenicillinV')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-
-def test_imatinib(chemidplus, imatinib, other_imatinib):
-    """Test record retrieval for imatinib."""
-    response = chemidplus.search('Imatinib')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], imatinib)
-
-    response = chemidplus.search('imatiniB')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], imatinib)
-
-    response = chemidplus.search('drugbank:DB00619')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 2
-    for record in response['records']:
-        if record['concept_id'] == 'chemidplus:152459-95-5':
-            compare_records(record, imatinib)
-        elif record['concept_id'] == 'chemidplus:220127-57-1':
-            compare_records(record, other_imatinib)
-        else:
-            assert False  # retrieved incorrect record
-
-    response = chemidplus.search('fda:BKJ8M8G5HI')
-    assert response['match_type'] == MatchType.ASSOCIATED_WITH
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], imatinib)
-
-
-def test_cisplatin(chemidplus, cisplatin):
-    """Test record retrieval for cisplatin."""
-    response = chemidplus.search('chemidplus:15663-27-1')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chemidplus.search('Cisplatin')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chemidplus.search('cis-Diaminedichloroplatinum')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chemidplus.search('drugbank:DB00515')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = chemidplus.search('Cisplatine')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-
-def test_meta(chemidplus):
-    """Test correctness of source metadata."""
-    response = chemidplus.search('incoherent-string-of-text')
-    assert response['source_meta_']['data_license'] == 'custom'
-    assert response['source_meta_']['data_license_url'] == 'https://www.nlm.nih.gov/databases/download/terms_and_conditions.html'  # noqa: E501
-    version = response['source_meta_']['version']
-    assert len(version) == 8
-    assert datetime.datetime.strptime(version, '%Y%m%d')
-    assert response['source_meta_']['data_url'] == 'ftp://ftp.nlm.nih.gov/nlmdata/.chemidlease/'  # noqa: E501
-    assert response['source_meta_']['rdp_url'] is None
-    assert response['source_meta_']['data_license_attributes'] == {
-        "non_commercial": False,
-        "share_alike": False,
-        "attribution": True
-    }
diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py
index 9881a35e..51937401 100644
--- a/tests/unit/test_database.py
+++ b/tests/unit/test_database.py
@@ -1,87 +1,54 @@
 """Test DynamoDB"""
-import pytest
-from therapy.database import Database
-from tests.conftest import TEST_ROOT
-import json
-import os
 from boto3.dynamodb.conditions import Key
 
+from tests.conftest import db  # noqa: F401
 
-@pytest.fixture(scope='module')
-def db():
-    """Create a DynamoDB test fixture."""
 
-    class DB:
-        def __init__(self):
-            self.db = Database()
-            if os.environ.get('TEST') is not None:
-                self.load_test_data()
-
-        def load_test_data(self):
-            with open(f'{TEST_ROOT}/tests/unit/'
-                      f'data/therapies.json', 'r') as f:
-                therapies = json.load(f)
-                with self.db.therapies.batch_writer() as batch:
-                    for therapy in therapies:
-                        batch.put_item(Item=therapy)
-                f.close()
-
-            with open(f'{TEST_ROOT}/tests/unit/'
-                      f'data/metadata.json', 'r') as f:
-                metadata = json.load(f)
-                with self.db.metadata.batch_writer() as batch:
-                    for m in metadata:
-                        batch.put_item(Item=m)
-                f.close()
-
-    return DB().db
-
-
-def test_tables_created(db):
+def test_tables_created(db):  # noqa: F811
     """Check that therapy_concepts and therapy_metadata are created."""
-    existing_tables = db.dynamodb_client.list_tables()['TableNames']
-    assert 'therapy_concepts' in existing_tables
-    assert 'therapy_metadata' in existing_tables
+    existing_tables = db.dynamodb_client.list_tables()["TableNames"]
+    assert "therapy_concepts" in existing_tables
+    assert "therapy_metadata" in existing_tables
 
 
-def test_item_type(db):
+def test_item_type(db):  # noqa: F811
     """Check that objects are tagged with item_type attribute."""
-    filter_exp = Key('label_and_type').eq('chembl:chembl11359##identity')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'identity'
-
-    filter_exp = Key('label_and_type').eq('interferon alfacon-1##label')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'label'
-
-    filter_exp = Key('label_and_type').eq('fda:5x5hb3vz3z##associated_with')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'associated_with'
-
-    filter_exp = Key('label_and_type').eq('drugbank:db00515##xref')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'xref'
-
-    filter_exp = Key('label_and_type').eq('dichlorodiammineplatinum##alias')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'alias'
-
-    filter_exp = Key('label_and_type').eq('rxcui:1041527##rx_brand')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'rx_brand'
-
-    filter_exp = Key('label_and_type').eq('align##trade_name')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'trade_name'
-
-    filter_exp = Key('label_and_type').eq('ncit:c839##merger')
-    item = db.therapies.query(KeyConditionExpression=filter_exp)['Items'][0]
-    assert 'item_type' in item
-    assert item['item_type'] == 'merger'
+    filter_exp = Key("label_and_type").eq("chembl:chembl11359##identity")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "identity"
+
+    filter_exp = Key("label_and_type").eq("interferon alfacon-1##label")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "label"
+
+    filter_exp = Key("label_and_type").eq("unii:5x5hb3vz3z##associated_with")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "associated_with"
+
+    filter_exp = Key("label_and_type").eq("drugbank:db00515##xref")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "xref"
+
+    filter_exp = Key("label_and_type").eq("dichlorodiammineplatinum##alias")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "alias"
+
+    filter_exp = Key("label_and_type").eq("rxcui:1041527##rx_brand")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "rx_brand"
+
+    filter_exp = Key("label_and_type").eq("align##trade_name")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "trade_name"
+
+    filter_exp = Key("label_and_type").eq("ncit:c839##merger")
+    item = db.therapies.query(KeyConditionExpression=filter_exp)["Items"][0]
+    assert "item_type" in item
+    assert item["item_type"] == "merger"
diff --git a/tests/unit/test_drugbank_source.py b/tests/unit/test_drugbank.py
similarity index 54%
rename from tests/unit/test_drugbank_source.py
rename to tests/unit/test_drugbank.py
index 69c45258..b75ab9f7 100644
--- a/tests/unit/test_drugbank_source.py
+++ b/tests/unit/test_drugbank.py
@@ -1,15 +1,16 @@
 """Test that the therapy normalizer works as intended for the DrugBank
 source.
 """
+import re
+
 import pytest
 
 from tests.conftest import compare_response
 from therapy.query import QueryHandler
 from therapy.schemas import Drug, MatchType
-import re
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def drugbank():
     """Build DrugBank normalizer test fixture."""
     class QueryGetter:
@@ -19,33 +20,33 @@ def __init__(self):
 
         def search(self, query_str):
             resp = self.query_handler.search_sources(query_str, keyed=True,
-                                                     incl='drugbank')
-            return resp['source_matches']['DrugBank']
+                                                     incl="drugbank")
+            return resp["source_matches"]["DrugBank"]
 
         def fetch_meta(self):
-            return self.query_handler._fetch_meta('DrugBank')
+            return self.query_handler._fetch_meta("DrugBank")
 
     return QueryGetter()
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def cisplatin():
     """Create a cisplatin drug fixture."""
     params = {
-        'label': 'Cisplatin',
-        'concept_id': 'drugbank:DB00515',
-        'aliases': [
-            'CDDP',
-            'Cis-DDP',
-            'cis-diamminedichloroplatinum(II)',
-            'APRD00359',
-            'cisplatino',
+        "label": "Cisplatin",
+        "concept_id": "drugbank:DB00515",
+        "aliases": [
+            "CDDP",
+            "Cis-DDP",
+            "cis-diamminedichloroplatinum(II)",
+            "APRD00359",
+            "cisplatino",
         ],
-        'xrefs': [
-            'chemidplus:15663-27-1',
+        "xrefs": [
+            "chemidplus:15663-27-1",
         ],
-        'trade_names': [],
-        'associated_with': [
+        "trade_names": [],
+        "associated_with": [
             "unii:Q20Q21Q62J",
             "inchikey:LXZZYRPGZAFOLE-UHFFFAOYSA-L"
         ],
@@ -53,24 +54,24 @@ def cisplatin():
     return Drug(**params)
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def bentiromide():
     """Create a bentiromide drug fixture."""
     params = {
-        'label': 'Bentiromide',
-        'concept_id': 'drugbank:DB00522',
-        'aliases': [
-            'APRD00818',
-            'Bentiromido',
-            'Bentiromidum',
-            'BTPABA',
-            'PFT',
+        "label": "Bentiromide",
+        "concept_id": "drugbank:DB00522",
+        "aliases": [
+            "APRD00818",
+            "Bentiromido",
+            "Bentiromidum",
+            "BTPABA",
+            "PFT",
         ],
-        'xrefs': [
-            'chemidplus:37106-97-1',
+        "xrefs": [
+            "chemidplus:37106-97-1",
         ],
-        'trade_names': [],
-        'associated_with': [
+        "trade_names": [],
+        "associated_with": [
             "unii:239IF5W61J",
             "inchikey:SPPTWHFVYKCNNK-FQEVSTJZSA-N"
         ],
@@ -78,17 +79,17 @@ def bentiromide():
     return Drug(**params)
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def aloe_ferox_leaf():
     """Create aloe ferox leaf fixture. Record has >20 aliases, so ETL deletes
     all of them.
     """
     params = {
-        'concept_id': 'drugbank:DB14257',
-        'label': 'Aloe ferox leaf',
-        'aliases': [],
-        'xrefs': [],
-        'trade_names': [],
+        "concept_id": "drugbank:DB14257",
+        "label": "Aloe ferox leaf",
+        "aliases": [],
+        "xrefs": [],
+        "trade_names": [],
         "associated_with": ["unii:0D145J8EME"]
     }
     return Drug(**params)
@@ -96,125 +97,125 @@ def aloe_ferox_leaf():
 
 def test_concept_id_match(drugbank, cisplatin, bentiromide, aloe_ferox_leaf):
     """Test that concept ID query resolves to correct record."""
-    response = drugbank.search('drugbank:DB00515')
+    response = drugbank.search("drugbank:DB00515")
     compare_response(response, MatchType.CONCEPT_ID, cisplatin)
 
-    response = drugbank.search('DB00515')
+    response = drugbank.search("DB00515")
     compare_response(response, MatchType.CONCEPT_ID, cisplatin)
 
-    response = drugbank.search('drugbank:db00515')
+    response = drugbank.search("drugbank:db00515")
     compare_response(response, MatchType.CONCEPT_ID, cisplatin)
 
-    response = drugbank.search('Drugbank:db00515')
+    response = drugbank.search("Drugbank:db00515")
     compare_response(response, MatchType.CONCEPT_ID, cisplatin)
 
-    response = drugbank.search('druGBank:DB00515')
+    response = drugbank.search("druGBank:DB00515")
     compare_response(response, MatchType.CONCEPT_ID, cisplatin)
 
-    response = drugbank.search('drugbank:DB00522')
+    response = drugbank.search("drugbank:DB00522")
     compare_response(response, MatchType.CONCEPT_ID, bentiromide)
 
-    response = drugbank.search('DB00522')
+    response = drugbank.search("DB00522")
     compare_response(response, MatchType.CONCEPT_ID, bentiromide)
 
-    response = drugbank.search('drugbank:db00522')
+    response = drugbank.search("drugbank:db00522")
     compare_response(response, MatchType.CONCEPT_ID, bentiromide)
 
-    response = drugbank.search('Drugbank:db00522')
+    response = drugbank.search("Drugbank:db00522")
     compare_response(response, MatchType.CONCEPT_ID, bentiromide)
 
-    response = drugbank.search('druGBank:DB00522')
+    response = drugbank.search("druGBank:DB00522")
     compare_response(response, MatchType.CONCEPT_ID, bentiromide)
 
-    response = drugbank.search('drugbank:DB14257')
+    response = drugbank.search("drugbank:DB14257")
     compare_response(response, MatchType.CONCEPT_ID, aloe_ferox_leaf)
 
 
 def test_label_match(drugbank, cisplatin, bentiromide, aloe_ferox_leaf):
     """Test that label query resolves to correct record."""
-    response = drugbank.search('cisplatin')
+    response = drugbank.search("cisplatin")
     compare_response(response, MatchType.LABEL, cisplatin)
 
-    response = drugbank.search('cisplatin')
+    response = drugbank.search("cisplatin")
     compare_response(response, MatchType.LABEL, cisplatin)
 
-    response = drugbank.search('Bentiromide')
+    response = drugbank.search("Bentiromide")
     compare_response(response, MatchType.LABEL, bentiromide)
 
-    response = drugbank.search('bentiromide')
+    response = drugbank.search("bentiromide")
     compare_response(response, MatchType.LABEL, bentiromide)
 
-    response = drugbank.search('aloe ferox leaf')
+    response = drugbank.search("aloe ferox leaf")
     compare_response(response, MatchType.LABEL, aloe_ferox_leaf)
 
 
 def test_alias_match(drugbank, cisplatin, bentiromide, aloe_ferox_leaf):
     """Test that alias query resolves to correct record."""
-    response = drugbank.search('CISPLATINO')
+    response = drugbank.search("CISPLATINO")
     compare_response(response, MatchType.ALIAS, cisplatin)
 
-    response = drugbank.search('Cis-ddp')
+    response = drugbank.search("Cis-ddp")
     compare_response(response, MatchType.ALIAS, cisplatin)
 
-    response = drugbank.search('APRD00818')
+    response = drugbank.search("APRD00818")
     compare_response(response, MatchType.ALIAS, bentiromide)
 
-    response = drugbank.search('PFT')
+    response = drugbank.search("PFT")
     compare_response(response, MatchType.ALIAS, bentiromide)
 
     # verify aliases > 20 not stored
-    response = drugbank.search('Aloe Capensis')
-    assert response['match_type'] == MatchType.NO_MATCH
+    response = drugbank.search("Aloe Capensis")
+    assert response["match_type"] == MatchType.NO_MATCH
 
-    response = drugbank.search('Aloe Ferox Juice')
-    assert response['match_type'] == MatchType.NO_MATCH
+    response = drugbank.search("Aloe Ferox Juice")
+    assert response["match_type"] == MatchType.NO_MATCH
 
 
 def test_xref_match(drugbank, cisplatin, bentiromide):
     """Test that xref query resolves to correct record."""
-    response = drugbank.search('chemidplus:15663-27-1')
+    response = drugbank.search("chemidplus:15663-27-1")
     compare_response(response, MatchType.XREF, cisplatin)
 
-    response = drugbank.search('chemidplus:37106-97-1')
+    response = drugbank.search("chemidplus:37106-97-1")
     compare_response(response, MatchType.XREF, bentiromide)
 
 
 def test_assoc_with_match(drugbank, cisplatin, bentiromide, aloe_ferox_leaf):
     """Test that associated_with query resolves to correct record."""
-    response = drugbank.search('inchikey:lxzzyrpgzafole-uhfffaoysa-l')
+    response = drugbank.search("inchikey:lxzzyrpgzafole-uhfffaoysa-l")
     compare_response(response, MatchType.ASSOCIATED_WITH, cisplatin)
 
-    response = drugbank.search('unii:239if5w61j')
+    response = drugbank.search("unii:239if5w61j")
     compare_response(response, MatchType.ASSOCIATED_WITH, bentiromide)
 
-    response = drugbank.search('UNII:0D145J8EME')
+    response = drugbank.search("UNII:0D145J8EME")
     compare_response(response, MatchType.ASSOCIATED_WITH, aloe_ferox_leaf)
 
 
 def test_no_match(drugbank):
     """Test that a term normalizes to correct drug concept as a NO match."""
-    response = drugbank.search('lepirudi')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
+    response = drugbank.search("lepirudi")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
 
     # Test white space in between id
-    response = drugbank.search('DB 00001')
-    assert response['match_type'] == MatchType.NO_MATCH
+    response = drugbank.search("DB 00001")
+    assert response["match_type"] == MatchType.NO_MATCH
 
     # Test empty query
-    response = drugbank.search('')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
+    response = drugbank.search("")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
 
 
 def test_meta_info(drugbank):
     """Test that the meta field is correct."""
     response = drugbank.fetch_meta()
-    assert response.data_license == 'CC0 1.0'
-    assert response.data_license_url == 'https://creativecommons.org/publicdomain/zero/1.0/'  # noqa: E501
-    assert re.match(r'[0-9]+\.[0-9]+\.[0-9]', response.version)
-    assert response.data_url == 'https://go.drugbank.com/releases/latest#open-data'  # noqa: E501
-    assert response.rdp_url == 'http://reusabledata.org/drugbank.html'  # noqa: E501
+    assert response.data_license == "CC0 1.0"
+    assert response.data_license_url == "https://creativecommons.org/publicdomain/zero/1.0/"  # noqa: E501
+    assert re.match(r"[0-9]+\.[0-9]+\.[0-9]", response.version)
+    assert response.data_url == "https://go.drugbank.com/releases/latest#open-data"
+    assert response.rdp_url == "http://reusabledata.org/drugbank.html"
     assert response.data_license_attributes == {
         "non_commercial": False,
         "share_alike": False,
diff --git a/tests/unit/test_drugsatfda.py b/tests/unit/test_drugsatfda.py
new file mode 100644
index 00000000..511d35b6
--- /dev/null
+++ b/tests/unit/test_drugsatfda.py
@@ -0,0 +1,430 @@
+"""Test correctness of Drugs@FDA ETL methods."""
+from typing import List
+
+import pytest
+import isodate
+
+from tests.conftest import compare_records
+from therapy.query import QueryHandler
+from therapy.schemas import MatchType, Drug
+
+
+@pytest.fixture(scope="module")
+def drugsatfda():
+    """Build Drugs@FDA test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.normalizer = QueryHandler()
+
+        def search(self, query_str):
+            resp = self.normalizer.search_sources(query_str, keyed=True,
+                                                  incl="drugsatfda")
+            return resp["source_matches"]["DrugsAtFDA"]
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def everolimus() -> Drug:
+    """Build afinitor test fixture."""
+    return Drug(**{
+        "label": "EVEROLIMUS",
+        "concept_id": "drugsatfda.nda:022334",
+        "xrefs": [
+            "rxcui:1119400",
+            "rxcui:1119402",
+            "rxcui:1308428",
+            "rxcui:1308430",
+            "rxcui:1308432",
+            "rxcui:1310138",
+            "rxcui:1310144",
+            "rxcui:1310147",
+            "rxcui:845507",
+            "rxcui:845512",
+            "rxcui:845515",
+            "rxcui:845518",
+            "rxcui:998189",
+            "rxcui:998191",
+        ],
+        "associated_with": [
+            "ndc:0078-0566",
+            "ndc:0078-0566",
+            "ndc:0078-0567",
+            "ndc:0078-0594",
+            "ndc:0078-0620",
+            "ndc:0078-0626",
+            "ndc:0078-0627",
+            "ndc:0078-0628",
+            "spl:8ad662ec-6e9b-4650-9f5a-78dda1f9f8f0",
+            "unii:9HW64Q8G6G",
+        ],
+        "approval_rating": "fda_prescription",
+        "trade_names": ["AFINITOR"]
+    })
+
+
+@pytest.fixture(scope="module")
+def dactinomycin() -> Drug:
+    """Build cosmegen test fixture."""
+    return Drug(**{
+        "label": "DACTINOMYCIN",
+        "concept_id": "drugsatfda.nda:050682",
+        "xrefs": [
+            "rxcui:105569",
+            "rxcui:239179"
+        ],
+        "associated_with": [
+            "ndc:55292-811",
+            "ndc:66993-489",
+            "spl:137c07d7-e2bf-4f59-8b8f-4ebe119e576d",
+            "spl:b92f5536-179c-6234-e053-2995a90a0f25",
+            "unii:1CC1JFE158",
+        ],
+        "approval_rating": "fda_prescription",
+        "trade_names": ["COSMEGEN"]
+    })
+
+
+@pytest.fixture(scope="module")
+def cisplatin() -> List[Drug]:
+    """Build cisplatin test fixtures"""
+    return [
+        Drug(**{
+            "label": "CISPLATIN",
+            "concept_id": "drugsatfda.anda:074656",
+            "xrefs": ["rxcui:309311"],
+            "associated_with": [
+                "ndc:0703-5747",
+                "ndc:0703-5748",
+                "spl:54b3415c-c095-4c82-b216-e0e6e6bb8d03",
+                "unii:Q20Q21Q62J",
+            ],
+            "approval_rating": "fda_prescription",
+        }),
+        Drug(**{
+            "label": "CISPLATIN",
+            "concept_id": "drugsatfda.anda:075036",
+            "xrefs": ["rxcui:309311"],
+            "associated_with": [
+                "ndc:0143-9504",
+                "ndc:0143-9505",
+                "spl:2c569ef0-588f-4828-8b2d-03a2120c9b4c",
+                "unii:Q20Q21Q62J",
+            ],
+            "approval_rating": "fda_prescription",
+        }),
+        Drug(**{
+            "label": "CISPLATIN",
+            "concept_id": "drugsatfda.anda:074735",
+            "xrefs": ["rxcui:309311"],
+            "associated_with": [
+                "ndc:63323-103",
+                "spl:9b008181-ab66-db2f-e053-2995a90aad57",
+                "unii:Q20Q21Q62J",
+            ],
+            "approval_rating": "fda_prescription",
+        }),
+        Drug(**{
+            "label": "CISPLATIN",
+            "concept_id": "drugsatfda.anda:206774",
+            "xrefs": ["rxcui:309311"],
+            "associated_with": [
+                "ndc:16729-288",
+                "ndc:68001-283",
+                "spl:c3ddc4a5-9f1b-a8ee-e053-2a95a90a2265",
+                "spl:c43de769-d6d8-3bb9-e053-2995a90a5aa2",
+                "unii:Q20Q21Q62J",
+            ],
+            "approval_rating": "fda_prescription",
+        }),
+        Drug(**{
+            "label": "CISPLATIN",
+            "concept_id": "drugsatfda.anda:207323",
+            "xrefs": ["rxcui:309311"],
+            "associated_with": [
+                "ndc:68083-162",
+                "ndc:68083-163",
+                "ndc:70860-206",
+                "spl:01c7a680-ee0d-42da-85e8-8d56c6fe7006",
+                "spl:5a24d5bd-c44a-43f7-a04c-76caf3475012",
+                "unii:Q20Q21Q62J",
+            ],
+            "approval_rating": "fda_prescription",
+        }),
+        Drug(**{
+            "label": "CISPLATIN",
+            "concept_id": "drugsatfda.nda:018057",
+            "xrefs": ["rxcui:1736854", "rxcui:309311"],
+            "associated_with": [
+                "ndc:44567-509",
+                "ndc:44567-510",
+                "ndc:44567-511",
+                "ndc:44567-530",
+                "spl:a66eda32-1164-439a-ac8e-73138365ec06",
+                "spl:dd45d777-d4c1-40ee-b4f0-c9e001a15a8c",
+                "unii:Q20Q21Q62J",
+            ],
+            "trade_names": ["PLATINOL-AQ", "PLATINOL"]
+        }),
+    ]
+
+
+@pytest.fixture(scope="module")
+def fenortho() -> List[Drug]:
+    """Provide fenortho fixture. Tests for correct handling of conflicting
+    approval_rating values.
+    """
+    return [
+        Drug(**{
+            "label": "FENOPROFEN CALCIUM",
+            "concept_id": "drugsatfda.anda:072267",
+            "xrefs": ["rxcui:310291", "rxcui:351398"],
+            "associated_with": [
+                "ndc:42195-471",
+                "ndc:42195-688",
+                "spl:c1bfb82a-814b-23f3-e053-2995a90ae9cf",
+                "spl:c1bfb82a-8192-23f3-e053-2995a90ae9cf",
+                "unii:0X2CW1QABJ",
+            ],
+            "approval_rating": "fda_prescription",
+            "trade_names": ["NALFON"]
+        }),
+        Drug(**{
+            "label": "FENOPROFEN CALCIUM",
+            "concept_id": "drugsatfda.nda:017604",
+            "xrefs": [
+                "rxcui:197694",
+                "rxcui:260323",
+                "rxcui:858116",
+                "rxcui:858118",
+            ],
+            "associated_with": [
+                "ndc:15014-400",
+                "ndc:42195-100",
+                "ndc:42195-308",
+                "ndc:42195-600",
+                "ndc:69336-113",
+                "ndc:69336-124",
+                "spl:81a0da5b-6078-413e-b6d0-d31c1073b3f7",
+                "spl:bedf1661-b811-3d85-e053-2995a90acd59",
+                "spl:c1721025-8af3-5451-e053-2a95a90a7dc0",
+                "spl:c1829550-d270-e13d-e053-2995a90a4aa5",
+                "spl:c187dfec-eec5-4227-e053-2995a90ab55c",
+                "unii:0X2CW1QABJ",
+            ],
+            "trade_names": ["NALFON", "FENORTHO"]
+        }),
+    ]
+
+
+def test_everolimus(drugsatfda, everolimus):
+    """Test everolimus/afinitor"""
+    concept_id = "drugsatfda.nda:022334"
+
+    # test concept ID
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], everolimus)
+
+    response = drugsatfda.search("drugsatfda.anda:022334")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # test label
+    response = drugsatfda.search("everolimus")
+    assert response["match_type"] == MatchType.LABEL
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+    # test trade name
+    response = drugsatfda.search("afinitor")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+    # test xref
+    response = drugsatfda.search("rxcui:998191")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+    response = drugsatfda.search("rxcui:845507")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+    response = drugsatfda.search("rxcui:998189")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+    response = drugsatfda.search("rxcui:1308432")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+    # test assoc_with
+    response = drugsatfda.search("unii:9HW64Q8G6G")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], everolimus)
+
+
+def test_dactinomycin(drugsatfda, dactinomycin):
+    """Test dactinomycin"""
+    concept_id = "drugsatfda.nda:050682"
+
+    # test concept ID
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], dactinomycin)
+
+    # test label
+    response = drugsatfda.search("DACTINOMYCIN")
+    assert response["match_type"] == MatchType.LABEL
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], dactinomycin)
+
+    # test trade name
+    response = drugsatfda.search("cosmegen")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], dactinomycin)
+
+    # test xref
+    response = drugsatfda.search("rxcui:105569")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], dactinomycin)
+
+    response = drugsatfda.search("rxcui:239179")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], dactinomycin)
+
+    # test assoc_with
+    response = drugsatfda.search("unii:1cc1jfe158")
+    assert response["match_type"] == MatchType.XREF
+    records = [r for r in response["records"] if r["concept_id"] == concept_id]
+    compare_records(records[0], dactinomycin)
+
+
+def test_cisplatin(drugsatfda, cisplatin):
+    """Test cisplatin"""
+    # test concept IDs
+    concept_id = "drugsatfda.anda:074656"
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0],
+                    [c for c in cisplatin if c.concept_id == concept_id][0])
+
+    concept_id = "drugsatfda.anda:075036"
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0],
+                    [c for c in cisplatin
+                     if c.concept_id.lower() == concept_id.lower()][0])
+
+    concept_id = "drugsatfda.anda:074735"
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0],
+                    [c for c in cisplatin if c.concept_id == concept_id][0])
+
+    concept_id = "drugsatfda.anda:206774"
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0],
+                    [c for c in cisplatin if c.concept_id == concept_id][0])
+
+    concept_id = "drugsatfda.anda:207323"
+    response = drugsatfda.search(concept_id)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0],
+                    [c for c in cisplatin if c.concept_id == concept_id][0])
+
+    # test label
+    response = drugsatfda.search("cisplatin")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 6
+    for r in response["records"]:
+        fixture = [c for c in cisplatin if r["concept_id"] == c.concept_id][0]
+        compare_records(r, fixture)
+
+    # test xref
+    response = drugsatfda.search("rxcui:309311")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 6
+    for r in response["records"]:
+        fixture = [c for c in cisplatin if r["concept_id"] == c.concept_id][0]
+        compare_records(r, fixture)
+
+    # test assoc_with
+    response = drugsatfda.search("unii:q20q21q62j")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 6
+    for r in response["records"]:
+        fixture = [c for c in cisplatin if r["concept_id"] == c.concept_id][0]
+        compare_records(r, fixture)
+
+
+def test_fenortho(fenortho, drugsatfda):
+    """Test fenortho."""
+    response = drugsatfda.search("drugsatfda.anda:072267")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fenortho[0])
+
+    response = drugsatfda.search("drugsatfda.nda:017604")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fenortho[1])
+
+    response = drugsatfda.search("fenoprofen calcium")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 2
+    for r in response["records"]:
+        fixture = [f for f in fenortho if r["concept_id"] == f.concept_id][0]
+        compare_records(r, fixture)
+
+    response = drugsatfda.search("rxcui:310291")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fenortho[0])
+
+    response = drugsatfda.search("unii:0X2CW1QABJ")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 2
+    for r in response["records"]:
+        fixture = [f for f in fenortho if r["concept_id"] == f.concept_id][0]
+        compare_records(r, fixture)
+
+
+def test_other_parameters(drugsatfda):
+    """Test other design decisions that aren"t captured in the defined
+    fixtures.
+    """
+    # test dropping BLA records
+    response = drugsatfda.search("drugsatfda:BLA020725")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+
+def test_meta(drugsatfda):
+    """Test correctness of source metadata."""
+    response = drugsatfda.search("incoherent-string-of-text")
+    assert response["source_meta_"]["data_license"] == "CC0"
+    assert response["source_meta_"]["data_license_url"] == "https://creativecommons.org/publicdomain/zero/1.0/legalcode"  # noqa: E501
+    assert isodate.parse_date(response["source_meta_"]["version"])
+    assert response["source_meta_"]["data_url"] == "https://open.fda.gov/apis/drug/drugsfda/download/"  # noqa: E501
+    assert response["source_meta_"]["rdp_url"] is None
+    assert response["source_meta_"]["data_license_attributes"] == {
+        "non_commercial": False,
+        "share_alike": False,
+        "attribution": False,
+    }
diff --git a/tests/unit/test_emit_warnings.py b/tests/unit/test_emit_warnings.py
index f90196ae..af87f36a 100644
--- a/tests/unit/test_emit_warnings.py
+++ b/tests/unit/test_emit_warnings.py
@@ -4,24 +4,27 @@
 
 def test_emit_warnings():
     """Test that emit_warnings works correctly."""
-    expected_warnings = {
-        'nbsp': 'Query contains non breaking space characters.'
-    }
+    expected_warnings = [
+        {
+            "non_breaking_space_characters":
+                "Query contains non-breaking space characters"
+        }
+    ]
     query_handler = QueryHandler()
 
     # Test emit no warnings
-    actual_warnings = query_handler._emit_warnings('CISPLATIN')
-    assert actual_warnings is None
+    actual_warnings = query_handler._emit_char_warnings("CISPLATIN")
+    assert actual_warnings == []
 
     # Test emit warnings
-    actual_warnings = query_handler._emit_warnings('CIS PLATIN')
+    actual_warnings = query_handler._emit_char_warnings("CIS PLATIN")
     assert actual_warnings == actual_warnings
 
-    actual_warnings = query_handler._emit_warnings('CIS\u00A0platin')
+    actual_warnings = query_handler._emit_char_warnings("CIS\u00A0platin")
     assert expected_warnings == actual_warnings
 
-    actual_warnings = query_handler._emit_warnings('CIS&nbsp;platin')
+    actual_warnings = query_handler._emit_char_warnings("CIS&nbsp;platin")
     assert expected_warnings == actual_warnings
 
-    actual_warnings = query_handler._emit_warnings('CIS\xa0platin')
+    actual_warnings = query_handler._emit_char_warnings("CIS\xa0platin")
     assert expected_warnings == actual_warnings
diff --git a/tests/unit/test_guidetopharmacology.py b/tests/unit/test_guidetopharmacology.py
new file mode 100644
index 00000000..bb72c7d4
--- /dev/null
+++ b/tests/unit/test_guidetopharmacology.py
@@ -0,0 +1,262 @@
+"""Test Guide to PHARMACOLOGY source."""
+import re
+
+import pytest
+
+from tests.conftest import compare_response
+from therapy.query import QueryHandler
+from therapy.schemas import Drug, MatchType
+
+
+@pytest.fixture(scope="module")
+def guidetopharmacology():
+    """Build Guide To PHARMACOLOGY test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.query_handler = QueryHandler()
+
+        def search(self, query_str):
+            resp = self.query_handler.search_sources(
+                query_str, keyed=True, incl="guidetopharmacology")
+            return resp["source_matches"]["GuideToPHARMACOLOGY"]
+
+        def fetch_meta(self):
+            return self.query_handler._fetch_meta("GuideToPHARMACOLOGY")
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def cisplatin():
+    """Create a cisplatin test fixture."""
+    params = {
+        "concept_id": "iuphar.ligand:5343",
+        "label": "cisplatin",
+        "approval_rating": "gtopdb_approved",
+        "xrefs": [
+            "chembl:CHEMBL11359",
+            "chemidplus:15663-27-1",
+            "drugbank:DB00515"
+        ],
+        "associated_with": [
+            "pubchem.substance:178102005",
+            "pubchem.compound:441203",
+            "CHEBI:27899"
+        ],
+        "aliases": [
+            "Platinol"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def arginine_vasotocin():
+    """Create an arginine vasotocin test fixture"""
+    params = {
+        "concept_id": "iuphar.ligand:2169",
+        "label": "arginine vasotocin",
+        "xrefs": [
+            "chemidplus:113-80-4"
+        ],
+        "associated_with": [
+            "pubchem.substance:135652004",
+            "pubchem.compound:68649",
+            "inchikey:OXDZADMCOWPSOC-ICBIOJHSSA-N"
+        ],
+        "aliases": [
+            "L-cysteinyl-L-tyrosyl-(3S)-DL-isoleucyl-L-glutaminyl-L-asparagyl-L-cysteinyl-DL-prolyl-L-arginyl-glycinamide (1->6)-disulfide",  # noqa: E501
+            "argiprestocin",
+            "[Arg<sup>8</sup>]vasotocin",
+            "AVT"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def phenobarbital():
+    """Create Phenobarbital test fixture"""
+    params = {
+        "concept_id": "iuphar.ligand:2804",
+        "label": "phenobarbital",
+        "approval_rating": "gtopdb_approved",
+        "xrefs": [
+            "chembl:CHEMBL40",
+            "chemidplus:50-06-6",
+            "drugbank:DB01174"
+        ],
+        "associated_with": [
+            "pubchem.substance:135650817",
+            "pubchem.compound:4763",
+            "CHEBI:8069",
+            "inchikey:DDBREPKUVSBGFI-UHFFFAOYSA-N",
+            "drugcentral:2134"
+        ],
+        "aliases": [
+            "5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione",
+            "fenobarbital",
+            "Luminal",
+            "phenobarb",
+            "phenobarbital sodium",
+            "phenobarbitone",
+            "phenylethylbarbiturate"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def cisapride():
+    """Create cisapride test fixture"""
+    params = {
+        "concept_id": "iuphar.ligand:240",
+        "label": "cisapride",
+        "approval_rating": "gtopdb_withdrawn",
+        "xrefs": [
+            "chembl:CHEMBL1729",
+            "chemidplus:81098-60-4",
+            "drugbank:DB00604"
+        ],
+        "associated_with": [
+            "pubchem.substance:135650104",
+            "pubchem.compound:2769",
+            "CHEBI:151790",
+            "inchikey:DCSUBABJRXZOMT-UHFFFAOYSA-N",
+            "drugcentral:660"
+        ],
+        "aliases": [
+            "4-amino-5-chloro-N-[1-[3-(4-fluorophenoxy)propyl]-3-methoxypiperidin-4-yl]-2-methoxybenzamide",  # noqa: E501
+            "Prepulsid",
+            "Propulsid"
+        ]
+    }
+    return Drug(**params)
+
+
+def test_concept_id_match(guidetopharmacology, cisplatin, arginine_vasotocin,
+                          phenobarbital, cisapride):
+    """Test that concept ID queries work correctly."""
+    resp = guidetopharmacology.search("iuphar.ligand:5343")
+    compare_response(resp, MatchType.CONCEPT_ID, cisplatin)
+
+    resp = guidetopharmacology.search("iuphar.ligand:2169")
+    compare_response(resp, MatchType.CONCEPT_ID, arginine_vasotocin)
+
+    resp = guidetopharmacology.search("iuphar.ligand:2804")
+    compare_response(resp, MatchType.CONCEPT_ID, phenobarbital)
+
+    resp = guidetopharmacology.search("iuphar.ligand:240")
+    compare_response(resp, MatchType.CONCEPT_ID, cisapride)
+
+
+def test_label_match(guidetopharmacology, cisplatin, arginine_vasotocin,
+                     phenobarbital, cisapride):
+    """Test that label queries work correctly."""
+    resp = guidetopharmacology.search("cisplatin")
+    compare_response(resp, MatchType.LABEL, cisplatin)
+
+    resp = guidetopharmacology.search("arginine vasotocin")
+    compare_response(resp, MatchType.LABEL, arginine_vasotocin)
+
+    resp = guidetopharmacology.search("Phenobarbital")
+    compare_response(resp, MatchType.LABEL, phenobarbital)
+
+    resp = guidetopharmacology.search("cisapride")
+    compare_response(resp, MatchType.LABEL, cisapride)
+
+
+def test_alias_match(guidetopharmacology, cisplatin, arginine_vasotocin,
+                     phenobarbital, cisapride):
+    """Test that alias queries work correctly."""
+    resp = guidetopharmacology.search("platinol")
+    compare_response(resp, MatchType.ALIAS, cisplatin)
+
+    resp = guidetopharmacology.search("AVT")
+    compare_response(resp, MatchType.ALIAS, arginine_vasotocin)
+
+    resp = guidetopharmacology.search("5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione")
+    compare_response(resp, MatchType.ALIAS, phenobarbital)
+
+    resp = guidetopharmacology.search("Prepulsid")
+    compare_response(resp, MatchType.ALIAS, cisapride)
+
+
+def test_xref_match(guidetopharmacology, cisplatin, arginine_vasotocin,
+                    phenobarbital, cisapride):
+    """Test that xref queries work correctly."""
+    resp = guidetopharmacology.search("chemidplus:15663-27-1")
+    compare_response(resp, MatchType.XREF, cisplatin)
+
+    resp = guidetopharmacology.search("chemidplus:113-80-4")
+    compare_response(resp, MatchType.XREF, arginine_vasotocin)
+
+    resp = guidetopharmacology.search("chembl:CHEMBL40")
+    compare_response(resp, MatchType.XREF, phenobarbital)
+
+    resp = guidetopharmacology.search("drugbank:DB00604")
+    compare_response(resp, MatchType.XREF, cisapride)
+
+
+def test_associated_with_match(guidetopharmacology, cisplatin,
+                               arginine_vasotocin, phenobarbital, cisapride):
+    """Test that associated_with queries work correctly."""
+    resp = guidetopharmacology.search("pubchem.substance:178102005")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, cisplatin)
+
+    resp = guidetopharmacology.search("pubchem.compound:441203")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, cisplatin)
+
+    resp = guidetopharmacology.search("pubchem.substance:135652004")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, arginine_vasotocin)
+
+    resp = guidetopharmacology.search("pubchem.compound:68649")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, arginine_vasotocin)
+
+    resp = guidetopharmacology.search("pubchem.substance:135650817")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, phenobarbital)
+
+    resp = guidetopharmacology.search("pubchem.compound:4763")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, phenobarbital)
+
+    resp = guidetopharmacology.search("drugcentral:2134")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, phenobarbital)
+
+    resp = guidetopharmacology.search("pubchem.substance:135650104")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, cisapride)
+
+    resp = guidetopharmacology.search("pubchem.compound:2769")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, cisapride)
+
+    resp = guidetopharmacology.search("inchikey:DCSUBABJRXZOMT-UHFFFAOYSA-N")
+    compare_response(resp, MatchType.ASSOCIATED_WITH, cisapride)
+
+
+def test_no_match(guidetopharmacology):
+    """Test that no match queries work correctly."""
+    resp = guidetopharmacology.search("178102005")
+    assert resp["match_type"] == MatchType.NO_MATCH
+    assert len(resp["records"]) == 0
+
+    resp = guidetopharmacology.search("guidetopharmacology:5343")
+    assert resp["match_type"] == MatchType.NO_MATCH
+    assert len(resp["records"]) == 0
+
+    resp = guidetopharmacology.search("")
+    assert resp["match_type"] == MatchType.NO_MATCH
+    assert len(resp["records"]) == 0
+
+
+def test_meta_info(guidetopharmacology):
+    """Test that metadata is correct."""
+    resp = guidetopharmacology.fetch_meta()
+    assert resp.data_license == "CC BY-SA 4.0"
+    assert resp.data_license_url == "https://creativecommons.org/licenses/by-sa/4.0/"
+    assert re.match(r"\d{4}.\d+", resp.version)
+    assert resp.data_url == "https://www.guidetopharmacology.org/download.jsp"
+    assert resp.rdp_url is None
+    assert resp.data_license_attributes == {
+        "non_commercial": False,
+        "attribution": True,
+        "share_alike": True
+    }
diff --git a/tests/unit/test_hemonc_source.py b/tests/unit/test_hemonc.py
similarity index 80%
rename from tests/unit/test_hemonc_source.py
rename to tests/unit/test_hemonc.py
index 49dea45d..8afda779 100644
--- a/tests/unit/test_hemonc_source.py
+++ b/tests/unit/test_hemonc.py
@@ -2,14 +2,14 @@
 source.
 """
 import pytest
+import isodate
 
-from tests.conftest import compare_response
 from therapy.query import QueryHandler
-from therapy.schemas import Drug, MatchType, ApprovalStatus
-import re
+from therapy.schemas import Drug, MatchType
+from tests.conftest import compare_response
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def hemonc():
     """Build HemOnc normalizer test fixture."""
     class QueryGetter:
@@ -19,16 +19,16 @@ def __init__(self):
 
         def search(self, query_str):
             resp = self.query_handler.search_sources(query_str, keyed=True,
-                                                     incl='hemonc')
-            return resp['source_matches']['HemOnc']
+                                                     incl="hemonc")
+            return resp["source_matches"]["HemOnc"]
 
         def fetch_meta(self):
-            return self.query_handler._fetch_meta('HemOnc')
+            return self.query_handler._fetch_meta("HemOnc")
 
     return QueryGetter()
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def cisplatin():
     """Construct cisplatin fixture."""
     return Drug(**{
@@ -46,7 +46,7 @@ def cisplatin():
         "trade_names": [],
         "xrefs": ["rxcui:2555"],
         "associated_with": [],
-        "approval_status": ApprovalStatus.APPROVED,
+        "approval_rating": "hemonc_approved",
         "approval_year": [1978],
         "has_indication": [
             {
@@ -68,7 +68,7 @@ def cisplatin():
     })
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def bendamustine():
     """Construct bendamustine fixture."""
     return Drug(**{
@@ -100,7 +100,7 @@ def bendamustine():
             "Treanda",
             "Xyotin"
         ],
-        "approval_status": ApprovalStatus.APPROVED,
+        "approval_rating": "hemonc_approved",
         "approval_year": ["2008", "2015"],
         "has_indication": [
             {
@@ -117,7 +117,7 @@ def bendamustine():
     })
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def degarelix():
     """Create fixture for degarelix drug."""
     return Drug(**{
@@ -131,7 +131,7 @@ def degarelix():
         "xrefs": ["rxcui:475230"],
         "associated_with": [],
         "trade_names": ["Firmagon"],
-        "approval_status": ApprovalStatus.APPROVED,
+        "approval_rating": "hemonc_approved",
         "approval_year": ["2008"],
         "has_indication": [
             {
@@ -145,74 +145,74 @@ def degarelix():
 
 def test_concept_id_match(hemonc, cisplatin, bendamustine, degarelix):
     """Test that concept ID queries resolve to correct record."""
-    response = hemonc.search('hemonc:105')
+    response = hemonc.search("hemonc:105")
     compare_response(response, MatchType.CONCEPT_ID, cisplatin)
 
-    response = hemonc.search('hemonc:65')
+    response = hemonc.search("hemonc:65")
     compare_response(response, MatchType.CONCEPT_ID, bendamustine)
 
-    response = hemonc.search('hemonc:151')
+    response = hemonc.search("hemonc:151")
     compare_response(response, MatchType.CONCEPT_ID, degarelix)
 
 
 def test_label_match(hemonc, cisplatin, bendamustine, degarelix):
     """Test that label queries resolve to correct record."""
-    response = hemonc.search('cisplatin')
+    response = hemonc.search("cisplatin")
     compare_response(response, MatchType.LABEL, cisplatin)
 
-    response = hemonc.search('Bendamustine')
+    response = hemonc.search("Bendamustine")
     compare_response(response, MatchType.LABEL, bendamustine)
 
-    response = hemonc.search('DEGARELIX')
+    response = hemonc.search("DEGARELIX")
     compare_response(response, MatchType.LABEL, degarelix)
 
 
 def test_alias_match(hemonc, cisplatin, bendamustine, degarelix):
     """Test that alias queries resolve to correct record."""
-    response = hemonc.search('ddp')
+    response = hemonc.search("ddp")
     compare_response(response, MatchType.ALIAS, cisplatin)
 
-    response = hemonc.search('dacp')
+    response = hemonc.search("dacp")
     compare_response(response, MatchType.ALIAS, cisplatin)
 
-    response = hemonc.search('nsc 119875')
+    response = hemonc.search("nsc 119875")
     compare_response(response, MatchType.ALIAS, cisplatin)
 
-    response = hemonc.search('cep-18083')
+    response = hemonc.search("cep-18083")
     compare_response(response, MatchType.ALIAS, bendamustine)
 
-    response = hemonc.search('bendamustine hydrochloride')
+    response = hemonc.search("bendamustine hydrochloride")
     compare_response(response, MatchType.ALIAS, bendamustine)
 
-    response = hemonc.search('asp3550')
+    response = hemonc.search("asp3550")
     compare_response(response, MatchType.ALIAS, degarelix)
 
 
 def test_trade_name(hemonc, bendamustine, degarelix):
     """Test that trade name queries resolve to correct record."""
-    response = hemonc.search('bendamax')
+    response = hemonc.search("bendamax")
     compare_response(response, MatchType.TRADE_NAME, bendamustine)
 
-    response = hemonc.search('purplz')
+    response = hemonc.search("purplz")
     compare_response(response, MatchType.TRADE_NAME, bendamustine)
 
-    response = hemonc.search('firmagon')
+    response = hemonc.search("firmagon")
     compare_response(response, MatchType.TRADE_NAME, degarelix)
 
     # no trade names for records with > 20
-    response = hemonc.search('platinol')
-    assert response['match_type'] == MatchType.NO_MATCH
+    response = hemonc.search("platinol")
+    assert response["match_type"] == MatchType.NO_MATCH
 
 
 def test_xref_match(hemonc, cisplatin, bendamustine, degarelix):
     """Test that xref query resolves to correct record."""
-    response = hemonc.search('rxcui:2555')
+    response = hemonc.search("rxcui:2555")
     compare_response(response, MatchType.XREF, cisplatin)
 
-    response = hemonc.search('rxcui:134547')
+    response = hemonc.search("rxcui:134547")
     compare_response(response, MatchType.XREF, bendamustine)
 
-    response = hemonc.search('rxcui:475230')
+    response = hemonc.search("rxcui:475230")
     compare_response(response, MatchType.XREF, degarelix)
 
 
@@ -221,7 +221,7 @@ def test_metadata(hemonc):
     response = hemonc.fetch_meta()
     assert response.data_license == "CC BY 4.0"
     assert response.data_license_url == "https://creativecommons.org/licenses/by/4.0/legalcode"  # noqa: E501
-    assert re.match(r'202[0-9][01][0-9][0-3][0-9]', response.version)
+    assert isodate.parse_date(response.version)
     assert response.data_url == "https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/9CY9C6"  # noqa: E501
     assert response.rdp_url is None
     assert response.data_license_attributes == {
diff --git a/tests/unit/test_merge.py b/tests/unit/test_merge.py
index d11087e1..d1e75653 100644
--- a/tests/unit/test_merge.py
+++ b/tests/unit/test_merge.py
@@ -1,13 +1,18 @@
 """Test merged record generation."""
+from typing import Dict
+import random
+
 import pytest
+
 from therapy.etl.merge import Merge
-from therapy.schemas import ApprovalStatus
-from typing import Dict
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def merge_handler(mock_database):
-    """Provide Merge instance to test cases."""
+    """Provide Merge instance to test cases.
+    Implements interfaces for basic merge functions, injects mock DB and
+    enables some additional backend checks for correctness.
+    """
     class MergeHandler:
         def __init__(self):
             self.merge = Merge(mock_database())
@@ -19,10 +24,10 @@ def create_merged_concepts(self, record_ids):
             return self.merge.create_merged_concepts(record_ids)
 
         def get_added_records(self):
-            return self.merge._database.added_records
+            return self.merge.database.added_records  # type: ignore
 
         def get_updates(self):
-            return self.merge._database.updates
+            return self.merge.database.updates  # type: ignore
 
         def create_record_id_set(self, record_id):
             return self.merge._create_record_id_set(record_id)
@@ -30,45 +35,42 @@ def create_record_id_set(self, record_id):
         def generate_merged_record(self, record_id_set):
             return self.merge._generate_merged_record(record_id_set)
 
-        def get_created_id_groups(self):
-            return self.merge._groups()
-
     return MergeHandler()
 
 
 def compare_merged_records(actual: Dict, fixture: Dict):
     """Check that records are identical."""
-    assert actual['concept_id'] == fixture['concept_id']
-    assert actual['label_and_type'] == fixture['label_and_type']
-    assert ('label' in actual) == ('label' in fixture)
-    if 'label' in actual or 'label' in fixture:
-        assert actual['label'] == fixture['label']
-    assert ('trade_names' in actual) == ('trade_names' in fixture)
-    if 'trade_names' in actual or 'trade_names' in fixture:
-        assert set(actual['trade_names']) == set(fixture['trade_names'])
-    assert ('aliases' in actual) == ('aliases' in fixture)
-    if 'aliases' in actual or 'aliases' in fixture:
-        assert set(actual['aliases']) == set(fixture['aliases'])
-
-    assert ('xrefs' in actual) == ('xrefs' in fixture)
-    if 'xrefs' in actual or 'xrefs' in fixture:
-        assert set(actual['xrefs']) == set(fixture['xrefs'])
-    assert ('associated_with' in actual) == ('associated_with' in fixture)
-    if 'associated_with' in actual or 'associated_with' in fixture:
-        assert set(actual['associated_with']) == \
-            set(fixture['associated_with'])
-
-    assert ('approval_status' in actual) == ('approval_status' in fixture)
-    if 'approval_status' in actual or 'approval_status' in fixture:
-        assert set(actual['approval_status']) == \
-            set(fixture['approval_status'])
-    assert ('approval_year' in actual) == ('approval_year' in fixture)
-    if 'approval_year' in actual or 'approval_year' in fixture:
-        assert set(actual['approval_year']) == set(fixture['approval_year'])
-    assert ('fda_indication' in actual) == ('fda_indication' in fixture)
-    if 'fda_indication' in actual or 'fda_indication' in fixture:
-        actual_inds = actual['fda_indication'].copy()
-        fixture_inds = fixture['fda_indication'].copy()
+    assert actual["concept_id"] == fixture["concept_id"]
+    assert actual["label_and_type"] == fixture["label_and_type"]
+    assert ("label" in actual) == ("label" in fixture)
+    if "label" in actual or "label" in fixture:
+        assert actual["label"] == fixture["label"]
+    assert ("trade_names" in actual) == ("trade_names" in fixture)
+    if "trade_names" in actual or "trade_names" in fixture:
+        assert set(actual["trade_names"]) == set(fixture["trade_names"])
+    assert ("aliases" in actual) == ("aliases" in fixture)
+    if "aliases" in actual or "aliases" in fixture:
+        assert set(actual["aliases"]) == set(fixture["aliases"])
+
+    assert ("xrefs" in actual) == ("xrefs" in fixture)
+    if "xrefs" in actual or "xrefs" in fixture:
+        assert set(actual["xrefs"]) == set(fixture["xrefs"])
+    assert ("associated_with" in actual) == ("associated_with" in fixture)
+    if "associated_with" in actual or "associated_with" in fixture:
+        assert set(actual["associated_with"]) == \
+            set(fixture["associated_with"])
+
+    assert ("approval_ratings" in actual) == ("approval_ratings" in fixture)
+    if "approval_ratings" in actual or "approval_ratings" in fixture:
+        assert set(actual["approval_ratings"]) == \
+            set(fixture["approval_ratings"])
+    assert ("approval_year" in actual) == ("approval_year" in fixture)
+    if "approval_year" in actual or "approval_year" in fixture:
+        assert set(actual["approval_year"]) == set(fixture["approval_year"])
+    assert ("has_indication" in actual) == ("has_indication" in fixture)
+    if "has_indication" in actual or "has_indication" in fixture:
+        actual_inds = actual["has_indication"].copy()
+        fixture_inds = fixture["has_indication"].copy()
         assert len(actual_inds) == len(fixture_inds)
         actual_inds.sort(key=lambda x: x[0])
         fixture_inds.sort(key=lambda x: x[0])
@@ -76,7 +78,7 @@ def compare_merged_records(actual: Dict, fixture: Dict):
             assert actual_inds[i] == fixture_inds[i]
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def phenobarbital_merged():
     """Create phenobarbital fixture."""
     return {
@@ -86,43 +88,54 @@ def phenobarbital_merged():
             "ncit:C739",
             "drugbank:DB01174",
             "chemidplus:50-06-6",
-            "wikidata:Q407241"
+            "wikidata:Q407241",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
         ],
         "aliases": [
-            '5-Ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione',
-            '5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione',
-            '5-Ethyl-5-phenylbarbituric acid',
-            '5-Phenyl-5-ethylbarbituric acid',
-            '5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione',
-            '5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione',
-            'Acid, Phenylethylbarbituric',
-            'APRD00184',
-            'Fenobarbital',
-            'Luminal®',
-            'PHENO',
-            'Phenemal',
-            'PHENOBARBITAL',
-            'PHENobarbital',
-            'Phenobarbital',
-            'Phenobarbital (substance)',
-            'Phenobarbital-containing product',
-            'Phenobarbitol',
-            'Phenobarbitone',
-            'Phenobarbituric Acid',
-            'Phenylaethylbarbitursaeure',
-            'Phenylbarbital',
-            'Phenylethylbarbiturate',
-            'Phenylethylbarbituric Acid',
-            'Phenylethylbarbitursaeure',
-            'Phenyläthylbarbitursäure',
-            'Phenylethylbarbitursäure',
-            'PHENYLETHYLMALONYLUREA',
-            'Phenylethylmalonylurea',
-            'Product containing phenobarbital (medicinal product)',
-            'fenobarbital',
-            'phenobarbital',
-            'phenobarbital sodium',
-            'phenylethylbarbiturate'
+            "5-Ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
+            "5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione",
+            "5-Ethyl-5-phenylbarbituric acid",
+            "5-Phenyl-5-ethylbarbituric acid",
+            "5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione",
+            "5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
+            "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione",
+            "APRD00184",
+            "Acid, Phenylethylbarbituric",
+            "Eskabarb",
+            "Fenobarbital",
+            "Luminal",
+            "Luminal®",
+            "NSC-128143",
+            "NSC-128143-",
+            "NSC-9848",
+            "Noptil",
+            "PHENO",
+            "PHENOBARBITAL",
+            "PHENYLETHYLMALONYLUREA",
+            "PHENobarbital",
+            "Phenemal",
+            "Phenobarbital",
+            "Phenobarbital civ",
+            "Phenobarbitol",
+            "Phenobarbitone",
+            "Phenobarbituric Acid",
+            "Phenylaethylbarbitursaeure",
+            "Phenylbarbital",
+            "Phenylethylbarbiturate",
+            "Phenylethylbarbituric Acid",
+            "Phenylethylbarbitursaeure",
+            "Phenylethylbarbitursäure",
+            "Phenylethylmalonylurea",
+            "Phenyläthylbarbitursäure",
+            "Solfoton",
+            "Talpheno",
+            "fenobarbital",
+            "phenobarb",
+            "phenobarbital",
+            "phenobarbital sodium",
+            "phenobarbitone",
+            "phenylethylbarbiturate"
         ],
         "associated_with": [
             "pubchem.compound:4763",
@@ -134,20 +147,26 @@ def phenobarbital_merged():
             "msh:D010634",
             "snomedct:373505007",
             "mmsl:5272",
-            "mthspl:YQE403BP4D",
+            "unii:YQE403BP4D",
             "fdbmk:001406",
             "mmsl:d00340",
             "atc:N03AA02",
-            "fda:YQE403BP4D",
             "umls:C0031412",
-            "chebi:CHEBI:8069",
-            "chembl:CHEMBL40"
+            "CHEBI:8069",
+            "inchikey:DDBREPKUVSBGFI-UHFFFAOYSA-N",
+            "drugcentral:2134",
+            "pubchem.substance:135650817"
         ],
         "label": "Phenobarbital",
+        "approval_ratings": [
+            "rxnorm_prescribable",
+            "chembl_phase_4",
+            "gtopdb_approved"
+        ]
     }
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def cisplatin_merged():
     """Create cisplatin fixture."""
     return {
@@ -156,64 +175,108 @@ def cisplatin_merged():
         "xrefs": [
             "ncit:C376",
             "drugbank:DB00515",
+            "drugbank:DB12117",
             "hemonc:105",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001"
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         ],
         "trade_names": [
             "Cisplatin",
-            "Platinol"
+            "Platinol",
+            "PLATINOL",
+            "PLATINOL-AQ"
         ],
         "aliases": [
-            '1,2-Diaminocyclohexaneplatinum II citrate',
-            'APRD00359',
-            'CDDP',
-            'CISplatin',
-            'Cis-DDP',
-            'CIS-DDP',
-            'DACP',
-            'DDP',
-            'Diamminodichloride, Platinum',
-            'Dichlorodiammineplatinum',
-            'Platinum Diamminodichloride',
-            'cis Diamminedichloroplatinum',
-            'cis Platinum',
-            'cis-Diaminedichloroplatinum',
-            'cis-Diamminedichloroplatinum',
-            'cis-diamminedichloroplatinum(II)',
-            'cis-Diamminedichloroplatinum(II)',
-            'cis-Dichlorodiammineplatinum(II)',
-            'cisplatinum',
-            'cis-Platinum',
-            'cis-platinum',
-            'cisplatino',
-            'cis-diamminedichloroplatinum(II)',
-            'cis-diamminedichloroplatinum III',
-            'NSC 119875',
-            'Platinol-AQ',
-            'Platinol'
+            "1,2-Diaminocyclohexaneplatinum II citrate",
+            "APRD00359",
+            "CDDP",
+            "CISplatin",
+            "Cisplatin",
+            "Cisplatinum",
+            "Cis-platinum ii",
+            "Cis-DDP",
+            "CIS-DDP",
+            "DACP",
+            "DDP",
+            "Diamminodichloride, Platinum",
+            "Dichlorodiammineplatinum",
+            "INT-230-6 COMPONENT CISPLATIN",
+            "INT230-6 COMPONENT CISPLATIN",
+            "Platinum Diamminodichloride",
+            "cis Diamminedichloroplatinum",
+            "cis Platinum",
+            "cis-Diaminedichloroplatinum",
+            "cis-Diamminedichloroplatinum",
+            "cis-diamminedichloroplatinum(II)",
+            "cis-Diamminedichloroplatinum(II)",
+            "cis-Dichlorodiammineplatinum(II)",
+            "cisplatinum",
+            "cis-Platinum",
+            "cis-platinum",
+            "cisplatino",
+            "cis-diamminedichloroplatinum(II)",
+            "cis-diamminedichloroplatinum III",
+            "NSC 119875",
+            "NSC-119875",
+            "Platinol-AQ",
+            "Platinol-aq",
+            "Platinol",
+            "Liplacis"
         ],
         "label": "cisplatin",
         "associated_with": [
-            "umls:C0008838",
-            "fda:Q20Q21Q62J",
+            "mmsl:31747",
+            "mmsl:4456",
+            "mmsl:d00195",
             "usp:m17910",
-            "vandf:4018139",
+            "inchikey:LXZZYRPGZAFOLE-UHFFFAOYSA-L",
+            "inchikey:MOTIYCLHZZLHHQ-UHFFFAOYSA-N",
             "mesh:D002945",
-            "mthspl:Q20Q21Q62J",
-            "mmsl:d00195",
             "atc:L01XA01",
-            "mmsl:31747",
-            "mmsl:4456",
+            "vandf:4018139",
             "pubchem.compound:5702198",
+            "umls:C0008838",
+            "unii:H8MTN7XVC2",
             "unii:Q20Q21Q62J",
-            "inchikey:LXZZYRPGZAFOLE-UHFFFAOYSA-L",
-            "chembl:CHEMBL11359",
+            "ndc:0143-9504",
+            "ndc:0143-9505",
+            "ndc:0703-5747",
+            "ndc:0703-5748",
+            "ndc:16729-288",
+            "ndc:44567-509",
+            "ndc:44567-510",
+            "ndc:44567-511",
+            "ndc:44567-530",
+            "ndc:63323-103",
+            "ndc:68001-283",
+            "ndc:68083-162",
+            "ndc:68083-163",
+            "ndc:70860-206",
+            "spl:01c7a680-ee0d-42da-85e8-8d56c6fe7006",
+            "spl:5a24d5bd-c44a-43f7-a04c-76caf3475012",
+            "spl:a66eda32-1164-439a-ac8e-73138365ec06",
+            "spl:dd45d777-d4c1-40ee-b4f0-c9e001a15a8c",
+            "spl:2c569ef0-588f-4828-8b2d-03a2120c9b4c",
+            "spl:54b3415c-c095-4c82-b216-e0e6e6bb8d03",
+            "spl:9b008181-ab66-db2f-e053-2995a90aad57",
+            "spl:c3ddc4a5-9f1b-a8ee-e053-2a95a90a2265",
+            "spl:c43de769-d6d8-3bb9-e053-2995a90a5aa2"
+        ],
+        "approval_ratings": [
+            "rxnorm_prescribable",
+            "hemonc_approved",
+            "fda_prescription",
+            "chembl_phase_4"
         ],
-        "approval_status": ApprovalStatus.APPROVED,
         "approval_year": ["1978"],
-        "fda_indication": [
+        "has_indication": [
             ["hemonc:671", "Testicular cancer", "ncit:C7251"],
             ["hemonc:645", "Ovarian cancer", "ncit:C7431"],
             ["hemonc:569", "Bladder cancer", "ncit:C9334"]
@@ -221,7 +284,7 @@ def cisplatin_merged():
     }
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def spiramycin_merged():
     """Create fixture for spiramycin. The RxNorm entry should be inaccessible
     to this group.
@@ -230,7 +293,7 @@ def spiramycin_merged():
         "label_and_type": "ncit:c839##merger",
         "concept_id": "ncit:C839",
         "xrefs": [
-            'chemidplus:8025-81-8',
+            "chemidplus:8025-81-8",
         ],
         "label": "Spiramycin",
         "aliases": [
@@ -244,138 +307,333 @@ def spiramycin_merged():
         ],
         "associated_with": [
             "umls:C0037962",
-            "fda:71ODY0V87H"
+            "unii:71ODY0V87H"
         ],
     }
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def record_id_groups():
     """Create fixture for concept group sets."""
     return {
-        "rxcui:8134": {
+        "rxcui:8134": {  # Phenobarbital
+            "rxcui:8134",
+            "ncit:C739",
+            "chemidplus:50-06-6",
+            "wikidata:Q407241",
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
+        },
+        "ncit:C739": {  # Phenobarbital
+            "rxcui:8134",
+            "ncit:C739",
+            "chemidplus:50-06-6",
+            "wikidata:Q407241",
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
+        },
+        "chemidplus:50-06-6": {  # Phenobarbital
             "rxcui:8134",
             "ncit:C739",
             "chemidplus:50-06-6",
             "wikidata:Q407241",
-            "drugbank:DB01174"
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
         },
-        "ncit:C739": {
+        "wikidata:Q407241": {  # Phenobarbital
             "rxcui:8134",
             "ncit:C739",
             "chemidplus:50-06-6",
             "wikidata:Q407241",
-            "drugbank:DB01174"
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
         },
-        "chemidplus:50-06-6": {
+        "drugbank:DB01174": {  # Phenobarbital
             "rxcui:8134",
             "ncit:C739",
             "chemidplus:50-06-6",
             "wikidata:Q407241",
-            "drugbank:DB01174"
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
         },
-        "wikidata:Q407241": {
+        "chembl:CHEMBL40": {  # Phenobarbital
             "rxcui:8134",
             "ncit:C739",
             "chemidplus:50-06-6",
             "wikidata:Q407241",
-            "drugbank:DB01174"
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
         },
-        "drugbank:DB01174": {
+        "iuphar.ligand:2804": {  # Phenobarbital
             "rxcui:8134",
             "ncit:C739",
             "chemidplus:50-06-6",
             "wikidata:Q407241",
-            "drugbank:DB01174"
+            "drugbank:DB01174",
+            "chembl:CHEMBL40",
+            "iuphar.ligand:2804"
         },
-        "ncit:C839": {
+        "ncit:C839": {  # Spiramycin
             "ncit:C839",
             "chemidplus:8025-81-8",
         },
-        "chemidplus:8025-81-8": {
+        "chemidplus:8025-81-8": {  # Spiramycin
             "ncit:C839",
             "chemidplus:8025-81-8",
         },
-        "rxcui:2555": {
+        "rxcui:2555": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "ncit:C376": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "chemidplus:15663-27-1": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "wikidata:Q412415": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "drugbank:DB00515": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "hemonc:105": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "chembl:CHEMBL11359": {  # Cisplatin
+            "rxcui:2555",
+            "ncit:C376",
+            "chemidplus:15663-27-1",
+            "wikidata:Q412415",
+            "drugbank:DB00515",
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
+        },
+        "drugsatfda.anda:074656": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "ncit:C376": {
+        "drugsatfda.anda:074735": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "chemidplus:15663-27-1": {
+        "drugsatfda.anda:206774": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "wikidata:Q412415": {
+        "drugsatfda.anda:207323": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "wikidata:Q47522001": {
+        "drugsatfda.anda:075036": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "drugbank:DB00515": {
+        "drugbank:DB12117": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "hemonc:105": {
+        "drugsatfda.nda:018057": {  # Cisplatin
             "rxcui:2555",
             "ncit:C376",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001",
             "drugbank:DB00515",
-            "hemonc:105"
+            "drugbank:DB12117",
+            "hemonc:105",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057"
         },
-        "rxcui:4126": {
+        # tests lookup of wikidata reference to rxnorm brand record, and
+        # drugbank reference to dead chemidplus record
+        "rxcui:4126": {  # Amifostine
             "rxcui:4126",
             "wikidata:Q47521576",
             "drugbank:DB01143"
         },
-        "wikidata:Q47521576": {
+        "wikidata:Q47521576": {  # Amifostine
             "rxcui:4126",
             "wikidata:Q47521576",
             "drugbank:DB01143"
         },
-        "drugbank:DB01143": {
+        "drugbank:DB01143": {  # Amifostine
             "rxcui:4126",
             "wikidata:Q47521576",
             "drugbank:DB01143"
         },
-        "ncit:C49236": {
+        "ncit:C49236": {  # Therapeutic Procedure
             "ncit:C49236"
+        },
+        # test exclusion of drugs@fda records with multiple UNIIs
+        "drugsatfda.nda:210595": {
+            "drugsatfda.nda:210595"
         }
     }
 
@@ -384,20 +642,30 @@ def test_create_record_id_set(merge_handler, record_id_groups):
     """Test creation of record ID sets. Queries DB and matches against
     record_id_groups fixture.
     """
-    # build groups from keys
-    for record_id in record_id_groups.keys():
-        new_group = merge_handler.create_record_id_set(record_id)
-        for concept_id in new_group:
-            merge_handler.merge._groups[concept_id] = new_group
-    groups = merge_handler.merge._groups
-
-    # perform checks
-    for concept_id in groups.keys():
-        assert groups[concept_id] == record_id_groups[concept_id]
-    assert len(groups) == len(record_id_groups)  # check if any are missing
+    # try a few different orders
+    keys = list(record_id_groups.keys())
+    key_len = len(keys)
+    order0 = list(range(key_len))
+    random.seed(42)
+    orders = [random.sample(order0, key_len) for _ in range(5)]
+    for order in [order0] + orders:
+        ordered_keys = [keys[i] for i in order]
+        merge_handler.merge._groups = {}
+
+        for record_id in ordered_keys:
+            new_group = merge_handler.create_record_id_set(record_id)
+            if new_group:
+                for concept_id in new_group:
+                    merge_handler.merge._groups[concept_id] = new_group
+        groups = merge_handler.merge._groups
+
+        # perform checks
+        for concept_id in groups.keys():
+            assert groups[concept_id] == record_id_groups[concept_id], f"{concept_id}"
+        assert len(groups) == len(record_id_groups)  # check if any are missing
 
     # test dead reference
-    has_dead_ref = 'ncit:C107245'
+    has_dead_ref = "ncit:C107245"
     dead_group = merge_handler.create_record_id_set(has_dead_ref)
     assert dead_group == {has_dead_ref}
 
@@ -406,15 +674,15 @@ def test_generate_merged_record(merge_handler, record_id_groups,
                                 phenobarbital_merged, cisplatin_merged,
                                 spiramycin_merged):
     """Test generation of merged record method."""
-    phenobarbital_ids = record_id_groups['rxcui:8134']
+    phenobarbital_ids = record_id_groups["rxcui:8134"]
     merge_response = merge_handler.generate_merged_record(phenobarbital_ids)
     compare_merged_records(merge_response, phenobarbital_merged)
 
-    cisplatin_ids = record_id_groups['rxcui:2555']
+    cisplatin_ids = record_id_groups["rxcui:2555"]
     merge_response = merge_handler.generate_merged_record(cisplatin_ids)
     compare_merged_records(merge_response, cisplatin_merged)
 
-    spiramycin_ids = record_id_groups['ncit:C839']
+    spiramycin_ids = record_id_groups["ncit:C839"]
     merge_response = merge_handler.generate_merged_record(spiramycin_ids)
     compare_merged_records(merge_response, spiramycin_merged)
 
@@ -430,35 +698,36 @@ def test_create_merged_concepts(merge_handler, record_id_groups,
     added_records = merge_handler.get_added_records()
     assert len(added_records) == 4
 
-    phenobarb_merged_id = phenobarbital_merged['concept_id']
+    phenobarb_merged_id = phenobarbital_merged["concept_id"]
     assert phenobarb_merged_id in added_records.keys()
     compare_merged_records(added_records[phenobarb_merged_id],
                            phenobarbital_merged)
 
-    cispl_merged_id = cisplatin_merged['concept_id']
+    cispl_merged_id = cisplatin_merged["concept_id"]
     assert cispl_merged_id in added_records.keys()
     compare_merged_records(added_records[cispl_merged_id], cisplatin_merged)
 
-    spira_merged_id = spiramycin_merged['concept_id']
+    spira_merged_id = spiramycin_merged["concept_id"]
     assert spira_merged_id in added_records.keys()
     compare_merged_records(added_records[spira_merged_id],
                            spiramycin_merged)
 
     # check merged record reference updating
     updates = merge_handler.get_updates()
-    for concept_id in record_id_groups['rxcui:8134']:
+    for concept_id in record_id_groups["rxcui:8134"]:
         assert updates[concept_id] == {
-            'merge_ref': phenobarbital_merged['concept_id'].lower()
+            "merge_ref": phenobarbital_merged["concept_id"].lower()
         }
-    for concept_id in record_id_groups['rxcui:2555']:
+    for concept_id in record_id_groups["rxcui:2555"]:
         assert updates[concept_id] == {
-            'merge_ref': cisplatin_merged['concept_id'].lower()
+            "merge_ref": cisplatin_merged["concept_id"].lower()
         }
-    for concept_id in record_id_groups['ncit:C839']:
+    for concept_id in record_id_groups["ncit:C839"]:
         assert updates[concept_id] == {
-            'merge_ref': spiramycin_merged['concept_id'].lower()
+            "merge_ref": spiramycin_merged["concept_id"].lower()
         }
 
     # no merged record for ncit:C49236 should be generated
-    assert len(updates) == len(record_id_groups) - 1
-    assert 'ncit:C49236' not in updates
+    assert len(updates) == len(record_id_groups) - 2
+    assert "ncit:C49236" not in updates
+    assert "drugsatfda.nda:210595" not in updates
diff --git a/tests/unit/test_ncit.py b/tests/unit/test_ncit.py
new file mode 100644
index 00000000..f19a7ee6
--- /dev/null
+++ b/tests/unit/test_ncit.py
@@ -0,0 +1,269 @@
+"""Test NCIT source"""
+import re
+
+import pytest
+
+from therapy.schemas import Drug, MatchType
+from therapy.query import QueryHandler
+from tests.conftest import compare_response
+
+
+@pytest.fixture(scope="module")
+def ncit():
+    """Build NCIt normalizer test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.query_handler = QueryHandler()
+
+        def search(self, query_str):
+            resp = self.query_handler.search_sources(query_str, keyed=True, incl="ncit")
+            return resp["source_matches"]["NCIt"]
+
+        def fetch_meta(self):
+            return self.query_handler._fetch_meta("NCIt")
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def voglibose():
+    """Create a voglibose drug fixture.."""
+    params = {
+        "label": "Voglibose",
+        "concept_id": "ncit:C95221",
+        "aliases": ["3,4-Dideoxy-4-((2-Hydroxy-1-(Hydroxymethyl)Ethyl)Amino)-2-C-(Hydroxymethyl)-D-Epi-Inositol",  # noqa F401
+                    "A-71100", "AO-128", "Basen",
+                    "N-(1,3-Dihydroxy-2-Propyl)Valiolamine", "VOGLIBOSE"],
+        "xrefs": ["chemidplus:83480-29-9"],
+        "associated_with": ["unii:S77P977AG8", "umls:C0532578"],
+        "approval_rating": None,
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def apricoxib():
+    """Create an apricoxib drug fixture."""
+    params = {
+        "label": "Apricoxib",
+        "concept_id": "ncit:C74021",
+        "aliases": [
+            "APRICOXIB", "COX-2 Inhibitor TG01", "CS-706", "R-109339",
+            "TG01", "TP2001"
+        ],
+        "xrefs": ["chemidplus:197904-84-0"],
+        "associated_with": ["unii:5X5HB3VZ3Z", "umls:C1737955"],
+        "approval_rating": None,
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+# Test aliases > 20
+@pytest.fixture(scope="module")
+def trastuzumab():
+    """Create a Trastuzumab drug fixture."""
+    params = {
+        "label": "Trastuzumab",
+        "concept_id": "ncit:C1647",
+        "aliases": [],
+        "xrefs": ["chemidplus:180288-69-1"],
+        "associated_with": ["umls:C0728747", "unii:P188ANX8CK"],
+        "approval_rating": None,
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+# Needed for MetaKB
+@pytest.fixture(scope="module")
+def therapeutic_procedure():
+    """Create a fixture for the Therapeutic Procedure class."""
+    params = {
+        "concept_id": "ncit:C49236",
+        "label": "Therapeutic Procedure",
+        "aliases": [
+            "any therapy",
+            "any_therapy",
+            "Therapeutic Interventions",
+            "Therapeutic Method",
+            "Therapeutic Technique",
+            "therapy",
+            "Therapy",
+            "TREAT",
+            "Treatment",
+            "TX",
+            "treatment",
+            "treatment or therapy",
+            "treatment_or_therapy",
+        ],
+        "trade_names": [],
+        "xrefs": [],
+        "associated_with": ["umls:C0087111"]
+    }
+    return Drug(**params)
+
+
+# test correct CHEBI naming
+@pytest.fixture(scope="module")
+def ivermectin():
+    """Create a fixture for Ivermectin."""
+    params = {
+        "label_and_type": "ncit:c61796",
+        "src_name": "NCIt",
+        "item_type": "identity",
+        "concept_id": "ncit:C61796",
+        "label": "Ivermectin",
+        "aliases": [
+            "Stromectol",
+            "IVERMECTIN",
+            "Avermectin A1a, 5-O-demethyl-25-de(1-methylpropyl)-22,23-dihydro-25-(1-methylethyl)-",  # noqa: E501
+            "Sklice"
+        ],
+        "xrefs": [
+            "chemidplus:70288-86-7"
+        ],
+        "associated_with": [
+            "umls:C0022322",
+            "unii:8883YP2R6D",
+            "CHEBI:6078"
+        ]
+    }
+    return Drug(**params)
+
+
+def test_concept_id_match(ncit, voglibose, apricoxib, trastuzumab,
+                          therapeutic_procedure, ivermectin):
+    """Test that concept ID query resolves to correct record."""
+    response = ncit.search("ncit:C95221")
+    compare_response(response, MatchType.CONCEPT_ID, voglibose)
+
+    response = ncit.search("NCIT:C95221")
+    compare_response(response, MatchType.CONCEPT_ID, voglibose)
+
+    response = ncit.search("NCIt:c95221")
+    compare_response(response, MatchType.CONCEPT_ID, voglibose)
+
+    response = ncit.search("C95221")
+    compare_response(response, MatchType.CONCEPT_ID, voglibose)
+
+    response = ncit.search("ncit:C74021")
+    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
+
+    response = ncit.search("NCIt:C74021")
+    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
+
+    response = ncit.search("ncit:c74021")
+    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
+
+    response = ncit.search("C74021")
+    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
+
+    response = ncit.search("ncit:C1647")
+    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
+
+    response = ncit.search("NCIT:C1647")
+    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
+
+    response = ncit.search("NCIt:c1647")
+    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
+
+    response = ncit.search("C1647")
+    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
+
+    response = ncit.search("ncit:C49236")
+    compare_response(response, MatchType.CONCEPT_ID, therapeutic_procedure)
+
+    response = ncit.search("NCIT:C61796")
+    compare_response(response, MatchType.CONCEPT_ID, ivermectin)
+
+
+def test_label_match(ncit, voglibose, apricoxib, trastuzumab):
+    """Test that label query resolves to correct record."""
+    response = ncit.search("voglibose")
+    compare_response(response, MatchType.LABEL, voglibose)
+
+    response = ncit.search("voglibose")
+    compare_response(response, MatchType.LABEL, voglibose)
+
+    response = ncit.search("trastuzumab")
+    compare_response(response, MatchType.LABEL, trastuzumab)
+
+    response = ncit.search("Trastuzumab")
+    compare_response(response, MatchType.LABEL, trastuzumab)
+
+    response = ncit.search("Apricoxib")
+    compare_response(response, MatchType.LABEL, apricoxib)
+
+    response = ncit.search("APRICOXIB")
+    compare_response(response, MatchType.LABEL, apricoxib)
+
+
+def test_alias_match(ncit, voglibose, apricoxib):
+    """Test that alias query resolves to correct record."""
+    response = ncit.search("BASEN")
+    compare_response(response, MatchType.ALIAS, voglibose)
+
+    response = ncit.search("AO-128")
+    compare_response(response, MatchType.ALIAS, voglibose)
+
+    response = ncit.search("COX-2 Inhibitor TG01")
+    compare_response(response, MatchType.ALIAS, apricoxib)
+
+
+def test_xref_match(ncit, voglibose, apricoxib, trastuzumab):
+    """Test that xref query resolves to correct record."""
+    response = ncit.search("chemidplus:83480-29-9")
+    compare_response(response, MatchType.XREF, voglibose)
+
+    response = ncit.search("CHEMIDPLUS:197904-84-0")
+    compare_response(response, MatchType.XREF, apricoxib)
+
+    response = ncit.search("chemidplus:180288-69-1")
+    compare_response(response, MatchType.XREF, trastuzumab)
+
+
+def test_assoc_with_match(ncit, voglibose, apricoxib, trastuzumab):
+    """Test that associated_with query resolves to correct record."""
+    response = ncit.search("unii:S77P977AG8")
+    compare_response(response, MatchType.ASSOCIATED_WITH, voglibose)
+
+    response = ncit.search("unii:5X5HB3VZ3Z")
+    compare_response(response, MatchType.ASSOCIATED_WITH, apricoxib)
+
+    response = ncit.search("unii:P188ANX8CK")
+    compare_response(response, MatchType.ASSOCIATED_WITH, trastuzumab)
+
+
+def test_no_match(ncit):
+    """Test that a term normalizes to NO match."""
+    response = ncit.search("voglibo")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+    # Test white space in between label
+    response = ncit.search("Volgibo")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Test empty query
+    response = ncit.search("")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+
+def test_meta_info(ncit):
+    """Test that the meta field is correct."""
+    response = ncit.fetch_meta()
+    assert response.data_license == "CC BY 4.0"
+    assert response.data_license_url == \
+        "https://creativecommons.org/licenses/by/4.0/legalcode"
+    assert re.match(r"[0-9][0-9]\.[0-9][0-9][a-z]", response.version)
+    assert response.data_url.startswith(
+        "https://evs.nci.nih.gov/ftp1/NCI_Thesaurus/")
+    assert response.rdp_url == "http://reusabledata.org/ncit.html"
+    assert response.data_license_attributes == {
+        "non_commercial": False,
+        "share_alike": False,
+        "attribution": True
+    }
diff --git a/tests/unit/test_ncit_source.py b/tests/unit/test_ncit_source.py
deleted file mode 100644
index 32ac3e6d..00000000
--- a/tests/unit/test_ncit_source.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""Test NCIT source"""
-import pytest
-from therapy.schemas import Drug, MatchType
-from therapy.query import QueryHandler
-from tests.conftest import compare_response
-
-
-@pytest.fixture(scope='module')
-def ncit():
-    """Build NCIt normalizer test fixture."""
-    class QueryGetter:
-
-        def __init__(self):
-            self.query_handler = QueryHandler()
-
-        def search(self, query_str):
-            resp = self.query_handler.search_sources(query_str, keyed=True,
-                                                     incl='ncit')
-            return resp['source_matches']['NCIt']
-    return QueryGetter()
-
-
-@pytest.fixture(scope='module')
-def voglibose():
-    """Create a voglibose drug fixture.."""
-    params = {
-        'label': 'Voglibose',
-        'concept_id': 'ncit:C95221',
-        'aliases': ['3,4-Dideoxy-4-((2-Hydroxy-1-(Hydroxymethyl)Ethyl)Amino)-2-C-(Hydroxymethyl)-D-Epi-Inositol',  # noqa F401
-                    'A-71100', 'AO-128', 'Basen',
-                    'N-(1,3-Dihydroxy-2-Propyl)Valiolamine', 'VOGLIBOSE'],
-        'xrefs': ['chemidplus:83480-29-9'],
-        'associated_with': ['fda:S77P977AG8', 'umls:C0532578'],
-        'approval_status': None,
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def apricoxib():
-    """Create an apricoxib drug fixture."""
-    params = {
-        'label': 'Apricoxib',
-        'concept_id': 'ncit:C74021',
-        'aliases': [
-            'APRICOXIB', 'COX-2 Inhibitor TG01', 'CS-706', 'R-109339',
-            'TG01', 'TP2001'
-        ],
-        'xrefs': ['chemidplus:197904-84-0'],
-        'associated_with': ['fda:5X5HB3VZ3Z', 'umls:C1737955'],
-        'approval_status': None,
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-# Test aliases > 20
-@pytest.fixture(scope='module')
-def trastuzumab():
-    """Create a Trastuzumab drug fixture."""
-    params = {
-        'label': 'Trastuzumab',
-        'concept_id': 'ncit:C1647',
-        'aliases': [],
-        'xrefs': ['chemidplus:180288-69-1'],
-        'associated_with': ['umls:C0728747', 'fda:P188ANX8CK'],
-        'approval_status': None,
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-# Needed for MetaKB
-@pytest.fixture(scope='module')
-def therapeutic_procedure():
-    """Create a fixture for the Therapeutic Procedure class."""
-    params = {
-        "concept_id": "ncit:C49236",
-        "label": "Therapeutic Procedure",
-        "aliases": [
-            "any therapy",
-            "any_therapy",
-            "Therapeutic Interventions",
-            "Therapeutic Method",
-            "Therapeutic Technique",
-            "therapy",
-            "Therapy",
-            "TREAT",
-            "Treatment",
-            "TX",
-            "therapeutic intervention",
-            "treatment"
-        ],
-        "trade_names": [],
-        "xrefs": [],
-        "associated_with": ["umls:C0087111"]
-    }
-    return Drug(**params)
-
-
-def test_concept_id_match(ncit, voglibose, apricoxib, trastuzumab,
-                          therapeutic_procedure):
-    """Test that concept ID query resolves to correct record."""
-    response = ncit.search('ncit:C95221')
-    compare_response(response, MatchType.CONCEPT_ID, voglibose)
-
-    response = ncit.search('NCIT:C95221')
-    compare_response(response, MatchType.CONCEPT_ID, voglibose)
-
-    response = ncit.search('NCIt:c95221')
-    compare_response(response, MatchType.CONCEPT_ID, voglibose)
-
-    response = ncit.search('C95221')
-    compare_response(response, MatchType.CONCEPT_ID, voglibose)
-
-    response = ncit.search('ncit:C74021')
-    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
-
-    response = ncit.search('NCIt:C74021')
-    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
-
-    response = ncit.search('ncit:c74021')
-    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
-
-    response = ncit.search('C74021')
-    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
-
-    response = ncit.search('C74021')
-    compare_response(response, MatchType.CONCEPT_ID, apricoxib)
-
-    response = ncit.search('ncit:C1647')
-    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
-
-    response = ncit.search('NCIT:C1647')
-    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
-
-    response = ncit.search('NCIt:c1647')
-    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
-
-    response = ncit.search('C1647')
-    compare_response(response, MatchType.CONCEPT_ID, trastuzumab)
-
-    response = ncit.search('ncit:C49236')
-    compare_response(response, MatchType.CONCEPT_ID, therapeutic_procedure)
-
-
-def test_label_match(ncit, voglibose, apricoxib, trastuzumab):
-    """Test that label query resolves to correct record."""
-    response = ncit.search('voglibose')
-    compare_response(response, MatchType.LABEL, voglibose)
-
-    response = ncit.search('voglibose')
-    compare_response(response, MatchType.LABEL, voglibose)
-
-    response = ncit.search('trastuzumab')
-    compare_response(response, MatchType.LABEL, trastuzumab)
-
-    response = ncit.search('Trastuzumab')
-    compare_response(response, MatchType.LABEL, trastuzumab)
-
-    response = ncit.search('Apricoxib')
-    compare_response(response, MatchType.LABEL, apricoxib)
-
-    response = ncit.search('APRICOXIB')
-    compare_response(response, MatchType.LABEL, apricoxib)
-
-
-def test_alias_match(ncit, voglibose, apricoxib):
-    """Test that alias query resolves to correct record."""
-    response = ncit.search('BASEN')
-    compare_response(response, MatchType.ALIAS, voglibose)
-
-    response = ncit.search('AO-128')
-    compare_response(response, MatchType.ALIAS, voglibose)
-
-    response = ncit.search('COX-2 Inhibitor TG01')
-    compare_response(response, MatchType.ALIAS, apricoxib)
-
-
-def test_xref_match(ncit, voglibose, apricoxib, trastuzumab):
-    """Test that xref query resolves to correct record."""
-    response = ncit.search('chemidplus:83480-29-9')
-    compare_response(response, MatchType.XREF, voglibose)
-
-    response = ncit.search('CHEMIDPLUS:197904-84-0')
-    compare_response(response, MatchType.XREF, apricoxib)
-
-    response = ncit.search('chemidplus:180288-69-1')
-    compare_response(response, MatchType.XREF, trastuzumab)
-
-
-def test_assoc_with_match(ncit, voglibose, apricoxib, trastuzumab):
-    """Test that associated_with query resolves to correct record."""
-    response = ncit.search('fda:S77P977AG8')
-    compare_response(response, MatchType.ASSOCIATED_WITH, voglibose)
-
-    response = ncit.search('fda:5X5HB3VZ3Z')
-    compare_response(response, MatchType.ASSOCIATED_WITH, apricoxib)
-
-    response = ncit.search('fda:P188ANX8CK')
-    compare_response(response, MatchType.ASSOCIATED_WITH, trastuzumab)
-
-
-def test_no_match(ncit):
-    """Test that a term normalizes to NO match."""
-    response = ncit.search('voglibo')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-    # Test white space in between label
-    response = ncit.search('Volgibo')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    # Test empty query
-    response = ncit.search('')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-
-def test_meta_info(ncit):
-    """Test that the meta field is correct."""
-    response = ncit.search('voglibose')
-    assert response['source_meta_']['data_license'] == 'CC BY 4.0'
-    assert response['source_meta_']['data_license_url'] == \
-        'https://creativecommons.org/licenses/by/4.0/legalcode'
-    assert response['source_meta_']['version'] == '20.09d'
-    assert response['source_meta_']['data_url'] == \
-        "https://evs.nci.nih.gov/ftp1/NCI_Thesaurus/archive/2020/20.09d_Release/"  # noqa: E501
-    assert response['source_meta_']['rdp_url'] == 'http://reusabledata.org/ncit.html'  # noqa: E501
-    assert response['source_meta_']['data_license_attributes'] == {
-        "non_commercial": False,
-        "share_alike": False,
-        "attribution": True
-    }
diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py
index 0d0e673a..266ff6be 100644
--- a/tests/unit/test_query.py
+++ b/tests/unit/test_query.py
@@ -1,11 +1,14 @@
 """Test the therapy querying method."""
+from datetime import datetime
+import os
+
+import pytest
+
 from therapy.query import QueryHandler, InvalidParameterException
 from therapy.schemas import MatchType
-import pytest
-from datetime import datetime
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def query_handler():
     """Build query handler test fixture."""
     class QueryGetter:
@@ -13,32 +16,35 @@ class QueryGetter:
         def __init__(self):
             self.query_handler = QueryHandler()
 
-        def search_sources(self, query_str, keyed=False, incl='', excl=''):
+        def search_sources(self, query_str, keyed=False, incl="", excl="",
+                           infer=True):
             resp = self.query_handler.search_sources(query_str=query_str,
                                                      keyed=keyed,
-                                                     incl=incl, excl=excl)
+                                                     incl=incl, excl=excl,
+                                                     infer=infer)
             return resp
 
     return QueryGetter()
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def merge_query_handler(mock_database):
     """Provide Merge instance to test cases."""
     class QueryGetter:
         def __init__(self):
-            self.query_handler = QueryHandler(db_url='http://localhost:8000')
-            self.query_handler.db = mock_database()  # replace initial DB
+            self.query_handler = QueryHandler()
+            if os.environ.get("TEST") is not None:
+                self.query_handler.db = mock_database()  # replace initial DB
 
-        def search_groups(self, query_str):
-            return self.query_handler.search_groups(query_str)
+        def search_groups(self, query_str, infer=True):
+            return self.query_handler.search_groups(query_str, infer)
 
     return QueryGetter()
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def phenobarbital():
-    """Create phenobarbital fixture."""
+    """Create phenobarbital VOD fixture."""
     return {
         "id": "normalize.therapy:Phenobarbital",
         "type": "TherapyDescriptor",
@@ -49,65 +55,89 @@ def phenobarbital():
             "drugbank:DB01174",
             "chemidplus:50-06-6",
             "wikidata:Q407241",
+            "iuphar.ligand:2804",
+            "chembl:CHEMBL40"
         ],
         "alternate_labels": [
-            '5-Ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione',
-            '5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione',
-            '5-Ethyl-5-phenylbarbituric acid',
-            '5-Phenyl-5-ethylbarbituric acid',
-            '5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione',
-            '5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione',
-            'APRD00184',
-            'Acid, Phenylethylbarbituric',
-            'Fenobarbital',
-            'Luminal®',
-            'PHENO',
-            'PHENOBARBITAL',
-            'PHENYLETHYLMALONYLUREA',
-            'PHENobarbital',
-            'Phenemal',
-            'Phenobarbital',
-            'Phenobarbitol',
-            'Phenobarbitone',
-            'Phenobarbituric Acid',
-            'Phenylaethylbarbitursaeure',
-            'Phenylbarbital',
-            'Phenylethylbarbiturate',
-            'Phenylethylbarbituric Acid',
-            'Phenylethylbarbitursaeure',
-            'Phenylethylbarbitursäure',
-            'Phenylethylmalonylurea',
-            'Phenyläthylbarbitursäure',
-            'fenobarbital',
-            'phenobarbital',
-            'phenobarbital sodium',
-            'phenylethylbarbiturate'
+            "5-Ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
+            "5-Ethyl-5-phenyl-pyrimidine-2,4,6-trione",
+            "5-Ethyl-5-phenylbarbituric acid",
+            "5-Phenyl-5-ethylbarbituric acid",
+            "5-ethyl-5-phenyl-2,4,6(1H,3H,5H)-pyrimidinetrione",
+            "5-ethyl-5-phenylpyrimidine-2,4,6(1H,3H,5H)-trione",
+            "APRD00184",
+            "Acid, Phenylethylbarbituric",
+            "Fenobarbital",
+            "Luminal®",
+            "PHENO",
+            "PHENOBARBITAL",
+            "PHENYLETHYLMALONYLUREA",
+            "PHENobarbital",
+            "Phenemal",
+            "Phenobarbital",
+            "Phenobarbitol",
+            "Phenobarbitone",
+            "Phenobarbituric Acid",
+            "Phenylaethylbarbitursaeure",
+            "Phenylbarbital",
+            "Phenylethylbarbiturate",
+            "Phenylethylbarbituric Acid",
+            "Phenylethylbarbitursaeure",
+            "Phenylethylbarbitursäure",
+            "Phenylethylmalonylurea",
+            "Phenyläthylbarbitursäure",
+            "fenobarbital",
+            "phenobarbital",
+            "phenobarbital sodium",
+            "phenylethylbarbiturate",
+            "5-ethyl-5-phenyl-1,3-diazinane-2,4,6-trione",
+            "phenobarb",
+            "phenobarbitone",
+            "NSC-128143",
+            "NSC-128143-",
+            "NSC-9848",
+            "Phenobarbital civ",
+            "Solfoton",
+            "Luminal",
+            "Eskabarb",
+            "Noptil",
+            "Talpheno"
         ],
         "extensions": [
             {
                 "type": "Extension",
                 "name": "associated_with",
                 "value": [
-                    "pubchem.compound:4763",
                     "usp:m63400",
                     "vandf:4017422",
                     "mmsl:2390",
                     "mesh:D010634",
                     "mmsl:5272",
-                    "mthspl:YQE403BP4D",
                     "mmsl:d00340",
                     "atc:N03AA02",
-                    "fda:YQE403BP4D",
+                    "unii:YQE403BP4D",
                     "umls:C0031412",
-                    "chebi:CHEBI:8069",
-                    "chembl:CHEMBL40"
+                    "CHEBI:8069",
+                    "pubchem.substance:135650817",
+                    "pubchem.compound:4763",
+                    "inchikey:DDBREPKUVSBGFI-UHFFFAOYSA-N",
+                    "drugcentral:2134"
                 ]
             },
+            {
+                "type": "Extension",
+                "name": "regulatory_approval",
+                "value": {
+                    "approval_ratings": [],
+                    "approval_year": [],
+                    "has_indication": []
+                }
+            }
         ]
     }
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def cisplatin():
     """Create cisplatin fixture."""
     return {
@@ -121,30 +151,51 @@ def cisplatin():
             "drugbank:DB00515",
             "chemidplus:15663-27-1",
             "wikidata:Q412415",
-            "wikidata:Q47522001"
+            "iuphar.ligand:5343",
+            "drugbank:DB12117",
+            "chembl:CHEMBL11359",
+            "drugsatfda.anda:074656",
+            "drugsatfda.anda:074735",
+            "drugsatfda.anda:206774",
+            "drugsatfda.anda:207323",
+            "drugsatfda.anda:075036",
+            "drugsatfda.nda:018057",
         ],
         "alternate_labels": [
-            '1,2-Diaminocyclohexaneplatinum II citrate',
-            'APRD00359',
-            'CDDP',
-            'CISplatin',
-            'Cis-DDP',
-            'CIS-DDP',
-            'DDP',
-            'Diamminodichloride, Platinum',
-            'Dichlorodiammineplatinum',
-            'Platinum Diamminodichloride',
-            'cis Diamminedichloroplatinum',
-            'cis Platinum',
-            'cis-Diaminedichloroplatinum',
-            'cis-Diamminedichloroplatinum',
-            'cis-Diamminedichloroplatinum(II)',
-            'cis-Dichlorodiammineplatinum(II)',
-            'cis-Platinum',
-            'cis-diamminedichloroplatinum(II)',
-            'Platinol-AQ',
-            'Platinol',
-            'cisplatino',
+            "1,2-Diaminocyclohexaneplatinum II citrate",
+            "APRD00359",
+            "CDDP",
+            "CISplatin",
+            "Cis-DDP",
+            "CIS-DDP",
+            "DDP",
+            "Diamminodichloride, Platinum",
+            "Dichlorodiammineplatinum",
+            "Platinum Diamminodichloride",
+            "cis Diamminedichloroplatinum",
+            "cis Platinum",
+            "cis-Diaminedichloroplatinum",
+            "cis-Diamminedichloroplatinum",
+            "cis-Diamminedichloroplatinum(II)",
+            "cis-Dichlorodiammineplatinum(II)",
+            "cis-Platinum",
+            "cis-diamminedichloroplatinum(II)",
+            "Platinol-AQ",
+            "Platinol",
+            "cisplatino",
+            "Cisplatin",
+            "Cis-platinum ii",
+            "Cisplatinum",
+            "INT-230-6 COMPONENT CISPLATIN",
+            "INT230-6 COMPONENT CISPLATIN",
+            "NSC-119875",
+            "Platinol-aq",
+            "DACP",
+            "NSC 119875",
+            "cis-diamminedichloroplatinum III",
+            "cis-platinum",
+            "cisplatinum",
+            "Liplacis"
         ],
         "extensions": [
             {
@@ -152,35 +203,61 @@ def cisplatin():
                 "name": "trade_names",
                 "value": [
                     "Cisplatin",
-                    "Platinol"
+                    "Platinol",
+                    "PLATINOL",
+                    "PLATINOL-AQ"
                 ],
             },
             {
                 "type": "Extension",
                 "name": "associated_with",
                 "value": [
-                    "mthspl:Q20Q21Q62J",
                     "mmsl:31747",
                     "mmsl:4456",
+                    "mmsl:d00195",
                     "usp:m17910",
-                    "chebi:CHEBI:27899",
+                    "CHEBI:27899",
                     "inchikey:LXZZYRPGZAFOLE-UHFFFAOYSA-L",
-                    "mmsl:d00195",
-                    "unii:Q20Q21Q62J",
+                    "inchikey:MOTIYCLHZZLHHQ-UHFFFAOYSA-N",
                     "mesh:D002945",
                     "atc:L01XA01",
                     "vandf:4018139",
+                    "pubchem.substance:178102005",
+                    "pubchem.compound:441203",
                     "pubchem.compound:5702198",
                     "umls:C0008838",
-                    "fda:Q20Q21Q62J",
-                    "chembl:CHEMBL11359",
+                    "unii:H8MTN7XVC2",
+                    "unii:Q20Q21Q62J",
+                    "ndc:0143-9504",
+                    "ndc:0143-9505",
+                    "ndc:0703-5747",
+                    "ndc:0703-5748",
+                    "ndc:16729-288",
+                    "ndc:44567-509",
+                    "ndc:44567-510",
+                    "ndc:44567-511",
+                    "ndc:44567-530",
+                    "ndc:63323-103",
+                    "ndc:68001-283",
+                    "ndc:68083-162",
+                    "ndc:68083-163",
+                    "ndc:70860-206",
+                    "spl:01c7a680-ee0d-42da-85e8-8d56c6fe7006",
+                    "spl:5a24d5bd-c44a-43f7-a04c-76caf3475012",
+                    "spl:a66eda32-1164-439a-ac8e-73138365ec06",
+                    "spl:dd45d777-d4c1-40ee-b4f0-c9e001a15a8c",
+                    "spl:2c569ef0-588f-4828-8b2d-03a2120c9b4c",
+                    "spl:54b3415c-c095-4c82-b216-e0e6e6bb8d03",
+                    "spl:9b008181-ab66-db2f-e053-2995a90aad57",
+                    "spl:c3ddc4a5-9f1b-a8ee-e053-2a95a90a2265",
+                    "spl:c43de769-d6d8-3bb9-e053-2995a90a5aa2"
                 ]
             },
             {
                 "type": "Extension",
-                "name": "fda_approval",
+                "name": "regulatory_approval",
                 "value": {
-                    "approval_status": "approved",
+                    "approval_ratings": [],
                     "approval_year": ["1978"],
                     "has_indication": [
                         {
@@ -208,7 +285,7 @@ def cisplatin():
     }
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def spiramycin():
     """Create fixture for spiramycin."""
     return {
@@ -217,7 +294,8 @@ def spiramycin():
         "therapy_id": "ncit:C839",
         "label": "Spiramycin",
         "xrefs": [
-            "chemidplus:8025-81-8"
+            "chemidplus:8025-81-8",
+            "wikidata:Q422265"
         ],
         "alternate_labels": [
             "SPIRAMYCIN",
@@ -226,7 +304,8 @@ def spiramycin():
             "Provamycin",
             "Rovamycine",
             "RP 5337",
-            "(4R,5S,6R,7R,9R,10R,11E,13E,16R)-10-{[(2R,5S,6R)-5-(dimethylamino)-6-methyltetrahydro-2H-pyran-2-yl]oxy}-9,16-dimethyl-5-methoxy-2-oxo-7-(2-oxoethyl)oxacyclohexadeca-11,13-dien-6-yl 3,6-dideoxy-4-O-(2,6-dideoxy-3-C-methyl-alpha-L-ribo-hexopyranosyl)-3-(dimethylamino)-alpha-D-glucopyranoside"  # noqa: E501
+            "(4R,5S,6R,7R,9R,10R,11E,13E,16R)-10-{[(2R,5S,6R)-5-(dimethylamino)-6-methyltetrahydro-2H-pyran-2-yl]oxy}-9,16-dimethyl-5-methoxy-2-oxo-7-(2-oxoethyl)oxacyclohexadeca-11,13-dien-6-yl 3,6-dideoxy-4-O-(2,6-dideoxy-3-C-methyl-alpha-L-ribo-hexopyranosyl)-3-(dimethylamino)-alpha-D-glucopyranoside",  # noqa: E501
+            "spiramycin I"
         ],
         "extensions": [
             {
@@ -234,15 +313,15 @@ def spiramycin():
                 "name": "associated_with",
                 "value": [
                     "umls:C0037962",
-                    "fda:71ODY0V87H",
-                    "chembl:CHEMBL1256397"
+                    "unii:71ODY0V87H",
+                    "pubchem.compound:5356392"
                 ]
             }
         ]
     }
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def therapeutic_procedure():
     """Create a fixture for the Therapeutic Procedure concept. Used to validate
     single-member concept groups for the normalize endpoint.
@@ -262,8 +341,9 @@ def therapeutic_procedure():
             "TREAT",
             "Treatment",
             "TX",
-            "therapeutic intervention",
-            "treatment"
+            "treatment",
+            "treatment or therapy",
+            "treatment_or_therapy",
         ],
         "extensions": [
             {
@@ -276,59 +356,62 @@ def therapeutic_procedure():
     }
 
 
-@pytest.fixture(scope='module')
-def ro_5045337():
-    """Create fixture for ro-5045337. Tests whether query correctly
-    builds VOD on the fly from non-merged record with xref to ChEMBL.
-    """
-    return {
-        "id": "normalize.therapy:wikidata#3AQ27287118",
-        "therapy_id": "wikidata:Q27287118",
-        "label": "ro-5045337",
-        "xrefs": ["chemidplus:939981-39-2"],
-        "alternate_labels": ["RO 5045337", "RG7112"],
-        "extensions": [
-            {
-                "name": "associated_with",
-                "value": ["chembl:CHEMBL2386346", "pubchem.compound:57406853"],
-                "type": "Extension"
-            }
-        ],
-        "type": "TherapyDescriptor"
-    }
-
-
 def compare_vod(response, fixture, query, match_type, response_id,
                 warnings=None):
-    """Verify correctness of returned VOD object against test fixture."""
-    assert response['query'] == query
+    """Verify correctness of returned VOD object against test fixture.
+
+    :param Dict response: actual response
+    :param Dict fixture: expected Descriptor object
+    :param str query: query used in search
+    :param MatchType match_type: expected MatchType
+    :param str response_id: expected response_id value
+    :param List warnings: expected warnings
+    """
     if warnings is None:
-        assert response['warnings'] is None
+        warnings = []
+
+    assert response["query"] == query
+
+    # check warnings
+    if warnings:
+        assert len(response["warnings"]) == len(warnings), "warnings len"
+        for e_warnings in warnings:
+            for r_warnings in response["warnings"]:
+                for e_key, e_val in e_warnings.items():
+                    for _, r_val in r_warnings.items():
+                        if e_key == r_val:
+                            if isinstance(e_val, list):
+                                assert set(r_val) == set(e_val), "warnings val"
+                            else:
+                                assert r_val == e_val, "warnings val"
     else:
-        assert response['warnings'] == warnings
-    assert response['match_type'] == match_type
+        assert response["warnings"] == [], "warnings != []"
+
+    assert response["match_type"] == match_type
 
     fixture = fixture.copy()
-    fixture['id'] = response_id
-    actual = response['therapy_descriptor']
+    fixture["id"] = response_id
+    actual = response["therapy_descriptor"]
 
-    assert actual['id'] == fixture['id']
-    assert actual['type'] == fixture['type']
-    assert actual['therapy_id'] == fixture['therapy_id']
-    assert actual['label'] == fixture['label']
+    assert actual["id"] == fixture["id"]
+    assert actual["type"] == fixture["type"]
+    assert actual["therapy_id"] == fixture["therapy_id"]
+    assert actual["label"] == fixture["label"]
 
-    assert ('xrefs' in actual.keys()) == ('xrefs' in fixture.keys())
-    if 'xrefs' in actual:
-        assert set(actual['xrefs']) == set(fixture['xrefs'])
+    assert ("xrefs" in actual.keys()) == ("xrefs" in fixture.keys())
+    if "xrefs" in actual:
+        print(actual["xrefs"])
+        print(fixture["xrefs"])
+        assert set(actual["xrefs"]) == set(fixture["xrefs"])
 
-    assert ('alternate_labels' in actual.keys()) == ('alternate_labels' in
+    assert ("alternate_labels" in actual.keys()) == ("alternate_labels" in
                                                      fixture.keys())
-    if 'alternate_labels' in actual:
-        assert set(actual['alternate_labels']) == \
-            set(fixture['alternate_labels'])
+    if "alternate_labels" in actual:
+        assert set(actual["alternate_labels"]) == \
+            set(fixture["alternate_labels"])
 
     def get_extension(extensions, name):
-        matches = [e for e in extensions if e['name'] == name]
+        matches = [e for e in extensions if e["name"] == name]
         if len(matches) > 1:
             assert False
         elif len(matches) == 1:
@@ -336,268 +419,352 @@ def get_extension(extensions, name):
         else:
             return None
 
-    assert ('extensions' in actual.keys()) == ('extensions' in fixture.keys())  # noqa: E501
-    if 'extensions' in actual:
-        ext_actual = actual['extensions']
-        ext_fixture = fixture['extensions']
-
-        assoc_actual = get_extension(ext_actual, 'associated_with')
-        assoc_fixture = get_extension(ext_fixture, 'associated_with')
+    assert ("extensions" in actual.keys()) == ("extensions" in fixture.keys())
+    if "extensions" in actual:
+        ext_actual = actual["extensions"]
+        ext_fixture = fixture["extensions"]
+
+        fda_actual = get_extension(ext_actual, "regulatory_approval")
+        fda_fixture = get_extension(ext_fixture, "regulatory_approval")
+        assert (fda_actual is None) == (fda_fixture is None), "regulatory_approval"
+        if fda_actual and fda_fixture:
+            ratings_actual = fda_actual.get("approval_ratings")
+            ratings_fixture = fda_fixture.get("approval_ratings")
+            if ratings_actual or ratings_fixture:
+                assert set(ratings_actual) == set(ratings_fixture)
+            assert set(fda_actual.get("approval_year", [])) == \
+                set(fda_fixture.get("approval_year", []))
+            assert set(fda_actual.get("has_indication", [])) == \
+                set(fda_fixture.get("has_indication", []))
+
+        assoc_actual = get_extension(ext_actual, "associated_with")
+        assoc_fixture = get_extension(ext_fixture, "associated_with")
         assert (assoc_actual is None) == (assoc_fixture is None)
         if assoc_actual:
-            assert set(assoc_actual['value']) == set(assoc_fixture['value'])
-            assert assoc_actual['value']
+            assert assoc_fixture is not None
+            assert set(assoc_actual["value"]) == set(assoc_fixture["value"])
+            assert assoc_actual["value"]
 
-        tn_actual = get_extension(ext_actual, 'trade_names')
-        tn_fixture = get_extension(ext_fixture, 'trade_names')
+        tn_actual = get_extension(ext_actual, "trade_names")
+        tn_fixture = get_extension(ext_fixture, "trade_names")
         assert (tn_actual is None) == (tn_fixture is None)
-        if tn_actual:
-            assert set(tn_actual['value']) == set(tn_fixture['value'])
-            assert tn_actual['value']
+        if tn_fixture:
+            assert tn_actual is not None
+            assert set(tn_actual["value"]) == set(tn_fixture["value"])
+            assert tn_actual["value"]
 
-        fda_actual = get_extension(ext_actual, 'fda_approval')
-        fda_fixture = get_extension(ext_fixture, 'fda_approval')
+        fda_actual = get_extension(ext_actual, "fda_approval")
+        fda_fixture = get_extension(ext_fixture, "fda_approval")
         assert (fda_actual is None) == (fda_fixture is None)
-        if fda_actual:
-            assert fda_actual.get('approval_status') == \
-                fda_fixture.get('approval_status')
-            assert set(fda_actual.get('approval_year', [])) == \
-                set(fda_fixture.get('approval_year', []))
-            assert set(fda_actual.get('has_indication', [])) == \
-                set(fda_fixture.get('has_indication', []))
+        if fda_fixture:
+            assert fda_actual is not None
+            assert fda_actual.get("approval_status") == \
+                fda_fixture.get("approval_status")
+            assert set(fda_actual.get("approval_year", [])) == \
+                set(fda_fixture.get("approval_year", []))
+            assert set(fda_actual.get("has_indication", [])) == \
+                set(fda_fixture.get("has_indication", []))
 
 
 def test_query(query_handler):
     """Test that query returns properly-structured response."""
-    resp = query_handler.search_sources('cisplatin', keyed=False)
-    assert resp['query'] == 'cisplatin'
-    matches = resp['source_matches']
+    resp = query_handler.search_sources("cisplatin", keyed=False)
+    assert resp["query"] == "cisplatin"
+    matches = resp["source_matches"]
     assert isinstance(matches, list)
-    assert len(matches) == 7
-    wikidata = list(filter(lambda m: m['source'] == 'Wikidata',
+    assert len(matches) == 9
+    wikidata = list(filter(lambda m: m["source"] == "Wikidata",
                            matches))[0]
-    assert len(wikidata['records']) == 1
-    wikidata_record = wikidata['records'][0]
-    assert wikidata_record['label'] == 'cisplatin'
+    assert len(wikidata["records"]) == 1
+    wikidata_record = wikidata["records"][0]
+    assert wikidata_record["label"] == "cisplatin"
 
 
 def test_query_keyed(query_handler):
     """Test that query structures matches as dict when requested."""
-    resp = query_handler.search_sources('penicillin v', keyed=True)
-    matches = resp['source_matches']
+    resp = query_handler.search_sources("penicillin v", keyed=True)
+    matches = resp["source_matches"]
     assert isinstance(matches, dict)
-    chemidplus = matches['ChemIDplus']
-    chemidplus_record = chemidplus['records'][0]
-    assert chemidplus_record['label'] == 'Penicillin V'
+    chemidplus = matches["ChemIDplus"]
+    chemidplus_record = chemidplus["records"][0]
+    assert chemidplus_record["label"] == "Penicillin V"
 
 
 def test_query_specify_sources(query_handler):
     """Test inclusion and exclusion of sources in query."""
     # test blank params
-    resp = query_handler.search_sources('cisplatin', keyed=True)
-    matches = resp['source_matches']
-    assert len(matches) == 7
-    assert 'Wikidata' in matches
-    assert 'ChEMBL' in matches
-    assert 'NCIt' in matches
-    assert 'DrugBank' in matches
-    assert 'ChemIDplus' in matches
-    assert 'RxNorm' in matches
-    assert 'HemOnc' in matches
+    resp = query_handler.search_sources("cisplatin", keyed=True)
+    assert set(resp["source_matches"].keys()) == {
+        "Wikidata", "ChEMBL", "NCIt", "DrugBank", "ChemIDplus", "RxNorm", "HemOnc",
+        "GuideToPHARMACOLOGY", "DrugsAtFDA"
+    }
 
     # test partial inclusion
-    resp = query_handler.search_sources('cisplatin', keyed=True,
-                                        incl='chembl,ncit')
-    matches = resp['source_matches']
-    assert len(matches) == 2
-    assert 'Wikidata' not in matches
-    assert 'ChEMBL' in matches
-    assert 'NCIt' in matches
-    assert 'DrugBank' not in matches
-    assert 'RxNorm' not in matches
-    assert 'ChemIDplus' not in matches
-    assert 'HemOnc' not in matches
+    resp = query_handler.search_sources("cisplatin", keyed=True,
+                                        incl="chembl,ncit")
+    assert set(resp["source_matches"].keys()) == {"ChEMBL", "NCIt"}
 
     # test full inclusion
-    sources = 'chembl,ncit,drugbank,wikidata,rxnorm,chemidplus,hemonc'
-    resp = query_handler.search_sources('cisplatin', keyed=True,
-                                        incl=sources, excl='')
-    matches = resp['source_matches']
-    assert len(matches) == 7
-    assert 'Wikidata' in matches
-    assert 'ChEMBL' in matches
-    assert 'NCIt' in matches
-    assert 'DrugBank' in matches
-    assert 'ChemIDplus' in matches
-    assert 'RxNorm' in matches
-    assert 'HemOnc' in matches
+    sources = "chembl,ncit,drugbank,wikidata,rxnorm,chemidplus,hemonc,guidetopharmacology,drugsatfda"  # noqa: E501
+    resp = query_handler.search_sources("cisplatin", keyed=True,
+                                        incl=sources, excl="")
+    assert set(resp["source_matches"].keys()) == {
+        "Wikidata", "ChEMBL", "NCIt", "DrugBank", "ChemIDplus", "RxNorm", "HemOnc",
+        "GuideToPHARMACOLOGY", "DrugsAtFDA"
+    }
 
     # test partial exclusion
-    resp = query_handler.search_sources('cisplatin', keyed=True,
-                                        excl='chemidplus')
-    matches = resp['source_matches']
-    assert len(matches) == 6
-    assert 'Wikidata' in matches
-    assert 'ChEMBL' in matches
-    assert 'NCIt' in matches
-    assert 'DrugBank' in matches
-    assert 'ChemIDplus' not in matches
-    assert 'RxNorm' in matches
-    assert 'HemOnc' in matches
+    resp = query_handler.search_sources("cisplatin", keyed=True,
+                                        excl="chemidplus")
+    assert set(resp["source_matches"].keys()) == {
+        "Wikidata", "ChEMBL", "NCIt", "DrugBank", "RxNorm", "HemOnc",
+        "GuideToPHARMACOLOGY", "DrugsAtFDA"
+    }
 
     # test full exclusion
+    sources = "chembl, wikidata, drugbank, ncit, rxnorm, chemidplus, hemonc, " \
+        "guidetopharmacology,drugsatfda"  # noqa: E501
     resp = query_handler.search_sources(
-        'cisplatin', keyed=True,
-        excl='chembl, wikidata, drugbank, ncit, rxnorm, chemidplus, hemonc'
+        "cisplatin", keyed=True,
+        excl=sources
     )
-    matches = resp['source_matches']
-    assert len(matches) == 0
-    assert 'Wikidata' not in matches
-    assert 'ChEMBL' not in matches
-    assert 'NCIt' not in matches
-    assert 'DrugBank' not in matches
-    assert 'ChemIDplus' not in matches
-    assert 'RxNorm' not in matches
-    assert 'HemOnc' not in matches
+    assert set(resp["source_matches"].keys()) == set()
 
     # test case insensitive
-    resp = query_handler.search_sources('cisplatin', keyed=True, excl='ChEmBl')
-    matches = resp['source_matches']
-    assert 'Wikidata' in matches
-    assert 'ChEMBL' not in matches
-    assert 'NCIt' in matches
-    assert 'DrugBank' in matches
-    assert 'ChemIDplus' in matches
-    assert 'RxNorm' in matches
-    assert 'HemOnc' in matches
-    resp = query_handler.search_sources('cisplatin', keyed=True,
-                                        incl='wIkIdAtA,cHeMbL')
-    matches = resp['source_matches']
-    assert 'Wikidata' in matches
-    assert 'ChEMBL' in matches
-    assert 'NCIt' not in matches
-    assert 'DrugBank' not in matches
-    assert 'ChemIDplus' not in matches
-    assert 'RxNorm' not in matches
-    assert 'HemOnc' not in matches
+    resp = query_handler.search_sources("cisplatin", keyed=True, excl="ChEmBl")
+    assert set(resp["source_matches"].keys()) == {
+        "Wikidata", "NCIt", "DrugBank", "ChemIDplus", "RxNorm", "HemOnc",
+        "GuideToPHARMACOLOGY", "DrugsAtFDA"
+    }
+
+    resp = query_handler.search_sources("cisplatin", keyed=True,
+                                        incl="wIkIdAtA,cHeMbL")
+    assert set(resp["source_matches"].keys()) == {"Wikidata", "ChEMBL"}
 
     # test error on invalid source names
     with pytest.raises(InvalidParameterException):
-        resp = query_handler.search_sources('cisplatin', keyed=True,
-                                            incl='chambl')
+        resp = query_handler.search_sources("cisplatin", keyed=True,
+                                            incl="chambl")
 
     # test error for supplying both incl and excl args
     with pytest.raises(InvalidParameterException):
-        resp = query_handler.search_sources('cisplatin', keyed=True,
-                                            incl='chembl', excl='wikidata')
+        resp = query_handler.search_sources("cisplatin", keyed=True,
+                                            incl="chembl", excl="wikidata")
+
+
+def test_infer_option(query_handler, merge_query_handler):
+    """Test infer_namespace boolean option"""
+    # drugbank
+    query = "DB01174"
+    expected_warnings = [{
+        "inferred_namespace": "drugbank",
+        "adjusted_query": "drugbank:" + query,
+        "alternate_inferred_matches": []
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["DrugBank"]["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    # ncit
+    query = "c739"
+    expected_warnings = [{
+        "inferred_namespace": "ncit",
+        "adjusted_query": "ncit:" + query,
+        "alternate_inferred_matches": []
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["NCIt"]["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    # chemidplus
+    query = "15663-27-1"
+    expected_warnings = [{
+        "inferred_namespace": "chemidplus",
+        "adjusted_query": "chemidplus:" + query,
+        "alternate_inferred_matches": [],
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["ChemIDplus"]["match_type"] == \
+        MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    # chembl
+    query = "chembl11359"
+    expected_warnings = [{
+        "inferred_namespace": "chembl",
+        "adjusted_query": "chembl:" + query,
+        "alternate_inferred_matches": [],
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["ChEMBL"]["match_type"] == \
+        MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    # wikidata
+    query = "q412415"
+    expected_warnings = [{
+        "inferred_namespace": "wikidata",
+        "adjusted_query": "wikidata:" + query,
+        "alternate_inferred_matches": [],
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["Wikidata"]["match_type"] == \
+        MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    # drugs@fda
+    query = "ANDA075036"
+    expected_warnings = [{
+        "inferred_namespace": "drugsatfda.anda",
+        "adjusted_query": "drugsatfda.anda:075036",
+        "alternate_inferred_matches": [],
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["DrugsAtFDA"]["match_type"] == \
+        MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    query = "nda018057"
+    expected_warnings = [{
+        "inferred_namespace": "drugsatfda.nda",
+        "adjusted_query": "drugsatfda.nda:018057",
+        "alternate_inferred_matches": [],
+    }]
+
+    response = query_handler.search_sources(query, keyed=True)
+    assert response["source_matches"]["DrugsAtFDA"]["match_type"] == \
+        MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    response = merge_query_handler.search_groups(query)
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert response["warnings"] == expected_warnings
+
+    # test disabling namespace inference
+    query = "DB01174"
+    response = merge_query_handler.search_groups(query, infer=False)
+    assert response["query"] == query
+    assert response["warnings"] == []
+    assert "record" not in response
+    assert response["match_type"] == MatchType.NO_MATCH
 
 
 def test_query_merged(merge_query_handler, phenobarbital, cisplatin,
-                      spiramycin, therapeutic_procedure, ro_5045337):
+                      spiramycin, therapeutic_procedure):
     """Test that the merged concept endpoint handles queries correctly."""
     # test merged id match
     query = "rxcui:2555"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, cisplatin, query, MatchType.CONCEPT_ID,
-                'normalize.therapy:rxcui%3A2555')
+                "normalize.therapy:rxcui%3A2555")
 
     # test concept id match
     query = "chemidplus:50-06-6"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, phenobarbital, query, MatchType.CONCEPT_ID,
-                'normalize.therapy:chemidplus%3A50-06-6')
+                "normalize.therapy:chemidplus%3A50-06-6")
 
     query = "hemonc:105"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, cisplatin, query, MatchType.CONCEPT_ID,
-                'normalize.therapy:hemonc%3A105')
+                "normalize.therapy:hemonc%3A105")
 
     # test label match
     query = "Phenobarbital"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, phenobarbital, query, MatchType.LABEL,
-                'normalize.therapy:Phenobarbital')
+                "normalize.therapy:Phenobarbital")
 
     # test trade name match
     query = "Platinol"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, cisplatin, query, MatchType.TRADE_NAME,
-                'normalize.therapy:Platinol')
+                "normalize.therapy:Platinol")
 
     # test alias match
     query = "cis Diamminedichloroplatinum"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, cisplatin, query, MatchType.ALIAS,
-                'normalize.therapy:cis%20Diamminedichloroplatinum')
+                "normalize.therapy:cis%20Diamminedichloroplatinum")
 
     query = "Rovamycine"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, spiramycin, query, MatchType.ALIAS,
-                'normalize.therapy:Rovamycine')
+                "normalize.therapy:Rovamycine")
 
     # test normalized group with single member
     query = "any therapy"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, therapeutic_procedure, query, MatchType.ALIAS,
-                'normalize.therapy:any%20therapy')
+                "normalize.therapy:any%20therapy")
 
     # test that term with multiple possible resolutions resolves at highest
     # match
     query = "Cisplatin"
     response = merge_query_handler.search_groups(query)
     compare_vod(response, cisplatin, query, MatchType.TRADE_NAME,
-                'normalize.therapy:Cisplatin')
+                "normalize.therapy:Cisplatin")
 
     # test whitespace stripping
     query = "   Cisplatin "
     response = merge_query_handler.search_groups(query)
     compare_vod(response, cisplatin, query, MatchType.TRADE_NAME,
-                'normalize.therapy:Cisplatin')
-
-    # test normalizing single-member group with chembl reference
-    query = "wikidata:Q27287118"
-    response = merge_query_handler.search_groups(query)
-    compare_vod(response, ro_5045337, query, MatchType.CONCEPT_ID,
-                'normalize.therapy:wikidata%3AQ27287118')
+                "normalize.therapy:Cisplatin")
 
     # test no match
     query = "zzzz fake therapy zzzz"
     response = merge_query_handler.search_groups(query)
-    assert response['query'] == query
-    assert response['warnings'] is None
-    assert 'record' not in response
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    query = "APRD00818"
-    response = merge_query_handler.search_groups(query)
-    assert response['query'] == query
-    assert response['warnings'] is None
-    assert 'record' not in response
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    query = "chembl:CHEMBL1200368"
-    response = merge_query_handler.search_groups(query)
-    assert response['query'] == query
-    assert response['warnings'] is None
-    assert 'record' not in response
-    assert response['match_type'] == MatchType.NO_MATCH
+    assert response["query"] == query
+    assert response["warnings"] == []
+    assert "record" not in response
+    assert response["match_type"] == MatchType.NO_MATCH
 
 
 def test_merged_meta(merge_query_handler):
     """Test population of source and resource metadata in merged querying."""
-    query = "pheno"
+    query = "phenobarbital"
     response = merge_query_handler.search_groups(query)
-    meta_items = response['source_meta_']
-    assert 'RxNorm' in meta_items.keys()
-    assert 'Wikidata' in meta_items.keys()
-    assert 'NCIt' in meta_items.keys()
-    assert 'ChemIDplus' in meta_items.keys()
+    meta_items = response["source_meta_"]
+    assert "RxNorm" in meta_items.keys()
+    assert "Wikidata" in meta_items.keys()
+    assert "NCIt" in meta_items.keys()
+    assert "ChemIDplus" in meta_items.keys()
 
     query = "RP 5337"
     response = merge_query_handler.search_groups(query)
-    meta_items = response['source_meta_']
-    assert 'NCIt' in meta_items.keys()
-    assert 'ChemIDplus' in meta_items.keys()
+    meta_items = response["source_meta_"]
+    assert "NCIt" in meta_items.keys()
+    assert "ChemIDplus" in meta_items.keys()
 
 
 def test_service_meta(query_handler, merge_query_handler):
@@ -605,24 +772,27 @@ def test_service_meta(query_handler, merge_query_handler):
     query = "pheno"
 
     response = query_handler.search_sources(query)
-    service_meta = response['service_meta_']
-    assert service_meta['name'] == "thera-py"
-    assert service_meta['version'] >= "0.2.13"
-    assert isinstance(service_meta['response_datetime'], datetime)
-    assert service_meta['url'] == 'https://github.com/cancervariants/therapy-normalization'  # noqa: E501
+    service_meta = response["service_meta_"]
+    assert service_meta["name"] == "thera-py"
+    assert service_meta["version"] >= "0.2.13"
+    assert isinstance(service_meta["response_datetime"], datetime)
+    assert service_meta["url"] == "https://github.com/cancervariants/therapy-normalization"  # noqa: E501
 
     response = merge_query_handler.search_groups(query)
-    service_meta = response['service_meta_']
-    assert service_meta['name'] == "thera-py"
-    assert service_meta['version'] >= "0.2.13"
-    assert isinstance(service_meta['response_datetime'], datetime)
-    assert service_meta['url'] == 'https://github.com/cancervariants/therapy-normalization'  # noqa: E501
+    service_meta = response["service_meta_"]
+    assert service_meta["name"] == "thera-py"
+    assert service_meta["version"] >= "0.2.13"
+    assert isinstance(service_meta["response_datetime"], datetime)
+    assert service_meta["url"] == "https://github.com/cancervariants/therapy-normalization"  # noqa: E501
 
 
 def test_broken_db_handling(merge_query_handler):
     """Test that query fails gracefully if mission-critical DB references are
     broken.
+
+    The test database includes an identity record (fake:00001) with a
+    purposely-broken merge_ref field. This lookup should not raise an
+    exception.
     """
-    # test broken merge ref
     query = "fake:00001"
     assert merge_query_handler.search_groups(query)
diff --git a/tests/unit/test_rxnorm.py b/tests/unit/test_rxnorm.py
new file mode 100644
index 00000000..9c25b159
--- /dev/null
+++ b/tests/unit/test_rxnorm.py
@@ -0,0 +1,940 @@
+"""Test that the therapy normalizer works as intended for the RxNorm source."""
+import os
+
+import pytest
+from boto3.dynamodb.conditions import Key
+import isodate
+
+from tests.conftest import compare_records
+from therapy.schemas import Drug, MatchType
+from therapy.query import QueryHandler
+from therapy.database import Database
+
+
+@pytest.fixture(scope="module")
+def rxnorm():
+    """Build RxNorm normalizer test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.normalizer = QueryHandler()
+            if "THERAPY_NORM_DB_URL" in os.environ:
+                db_url = os.environ["THERAPY_NORM_DB_URL"]
+            else:
+                db_url = "http://localhost:8000"
+            self.db = Database(db_url=db_url)
+
+        def search(self, query_str):
+            resp = self.normalizer.search_sources(query_str, keyed=True,
+                                                  incl="rxnorm")
+            return resp["source_matches"]["RxNorm"]
+
+        def fetch_meta(self):
+            return self.normalizer._fetch_meta("RxNorm")
+
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def bifidobacterium_infantis():
+    """Create bifidobacterium infantis drug fixture."""
+    params = {
+        "label": "Bifidobacterium Infantis",
+        "concept_id": "rxcui:100213",
+        "aliases": [
+            "bifidobacterium infantis",
+            "Bifidobacterium infantis"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB14222"
+        ],
+        "associated_with": [
+            "mmsl:d07347"
+        ],
+        "trade_names": [
+            "Align",
+            "Evivo"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def cisplatin():
+    """Create cisplatin drug fixture."""
+    params = {
+        "label": "cisplatin",
+        "concept_id": "rxcui:2555",
+        "aliases": [
+            "cis-Platinum",
+            "CISplatin",
+            "cis Diamminedichloroplatinum",
+            "cis Platinum",
+            "Diamminodichloride, Platinum",
+            "cis-Diamminedichloroplatinum",
+            "DDP",
+            "Dichlorodiammineplatinum",
+            "cis-Dichlorodiammineplatinum(II)",
+            "Platinum Diamminodichloride",
+            "cis-Diamminedichloroplatinum(II)",
+            "cis-diamminedichloroplatinum(II)",
+            "CDDP",
+            "Cis-DDP",
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB00515",
+            "drugbank:DB12117"
+        ],
+        "associated_with": [
+            "usp:m17910",
+            "vandf:4018139",
+            "mesh:D002945",
+            "unii:Q20Q21Q62J",
+            "mmsl:d00195",
+            "atc:L01XA01",
+            "mmsl:31747",
+            "mmsl:4456",
+        ],
+        "trade_names": [
+            "Cisplatin",
+            "Platinol"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def amiloride_hydrochloride():
+    """Create amiloride hydrochloride drug fixture."""
+    params = {
+        "label": "amiloride hydrochloride",
+        "concept_id": "rxcui:142424",
+        "aliases": [
+            "aMILoride hydrochloride",
+            "Amiloride Hydrochloride",
+            "Hydrochloride, Amiloride"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [],
+        "associated_with": [
+            "usp:m2650",
+            "unii:FZJ37245UC",
+            "mmsl:2658",
+            "mesh:D000584",
+            "mmsl:4166",
+            "vandf:4017935"
+        ],
+        "trade_names": [
+            "AMILoride Hydrochloride",
+            "Midamor",
+            "Aridil",
+            "Frusemek",
+            "Midamor",
+            "Moduret",
+            "Moduretic",
+            "Zida-Co"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def amiloride():
+    """Create amiloride drug fixture."""
+    params = {
+        "label": "amiloride",
+        "concept_id": "rxcui:644",
+        "aliases": [
+            "3,5-diamino-N-carbamimidoyl-6-chloropyrazine-2-carboxamide",
+            "aMILoride",
+            "Amipramidin",
+            "Amipramidine",
+            "N-amidino-3,5-diamino-6-chloropyrazinecarboxamide",
+            "Amyloride"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB00594"
+        ],
+        "associated_with": [
+            "mesh:D000584",
+            "vandf:4019603",
+            "unii:7DZO8EB0Z3",
+            "mmsl:d00169",
+            "atc:C03DB01"
+        ],
+        "trade_names": [
+            "Midamor",
+            "Frusemek",
+            "Moduret",
+            "Aridil",
+            "Zida-Co",
+            "Amilamont",
+            "Moduretic"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def timolol():
+    """Create timolol drug fixture."""
+    params = {
+        "label": "timolol",
+        "concept_id": "rxcui:10600",
+        "aliases": [
+            "(S)-1-((1,1-Dimethylethyl)amino)-3-((4-(4-morpholinyl)"
+            "-1,2,5-thiadazol-3-yl)oxy)-2-propanol",
+            "2-Propanol, 1-((1,1-dimethylethyl)amino)-3-((4-(4-morpholi"
+            "nyl)-1,2,5-thiadiazol-3-yl)oxy)-, (S)-",
+            "(S)-1-(tert-butylamino)-3-[(4-morpholin-4-yl-1,2,5-thiadiazol"
+            "-3-yl)oxy]propan-2-ol"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB00373"
+        ],
+        "associated_with": [
+            "vandf:4019949",
+            "unii:817W3C6175",
+            "mesh:D013999",
+            "mmsl:d00139",
+            "atc:C07AA06",
+            "atc:S01ED01"
+        ],
+        "trade_names": [
+            "Betimol",
+            "Timoptic",
+            "Istalol",
+            "Combigan",
+            "Cosopt",
+            "Timoptol LA",
+            "Blocadren",
+            "Timolide",
+            "Betim",
+            "Glau-Opt",
+            "Glaucol",
+            "Timoptol"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def lymphocyte():
+    """Create lymphocyte immune globulin, anti-thymocyte globulin drug
+    fixture.
+    """
+    params = {
+        "label": "lymphocyte immune globulin, anti-thymocyte globulin",
+        "concept_id": "rxcui:1011",
+        "aliases": [
+            "Lymphocyte Immune Globulin, Anti-Thymocyte Globulin",
+            "Anti-Thymocyte Globulin",
+            "Antithymocyte Globulin",
+            "Antithymoglobulin",
+            "Lymphocyte Immune Globulin, Anti Thymocyte Globulin",
+            "Globulins, Antithymocyte",
+            "Antithymocyte Globulins",
+            "Antithymoglobulins",
+            "Anti-Thymocyte Globulins",
+            "Globulins, Anti-Thymocyte",
+            "Globulin, Antithymocyte",
+            "Anti Thymocyte Globulin",
+            "Globulin, Anti-Thymocyte",
+            "lymphocyte immune globulin, anti-thy (obs)"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [],
+        "associated_with": [
+            "mesh:D000961",
+            "vandf:4022194",
+            "vandf:4018097",
+            "mmsl:d01141",
+            "mmsl:5001"
+        ],
+        "trade_names": [
+            "ATGAM",
+            "Thymoglobulin"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def aspirin():
+    """Create aspirin drug fixture."""
+    params = {
+        "label": "aspirin",
+        "concept_id": "rxcui:1191",
+        "aliases": [
+            "Salicylic acid acetate",
+            "Aspirin",
+            "Acid, Acetylsalicylic",
+            "2-(Acetyloxy)benzoic Acid",
+            "2-Acetoxybenzenecarboxylic acid",
+            "ASA",
+            "O-acetylsalicylic acid",
+            "2-Acetoxybenzoic acid",
+            "o-acetoxybenzoic acid",
+            "o-carboxyphenyl acetate",
+            "Acetylsalicylic Acid"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB00945"
+        ],
+        "associated_with": [
+            "usp:m6240",
+            "vandf:4017536",
+            "mmsl:34512",
+            "unii:R16CO5Y76E",
+            "mmsl:244",
+            "mesh:D001241",
+            "mmsl:4223",
+            "mmsl:d00170",
+            "atc:A01AD05",
+            "atc:B01AC06",
+            "atc:N02BA01"
+        ],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def mesna():
+    """Create mesna drug fixture."""
+    params = {
+        "label": "mesna",
+        "concept_id": "rxcui:44",
+        "aliases": [
+            "Mesnum",
+            "Ethanesulfonic acid, 2-mercapto-, monosodium salt",
+            "Sodium 2-Mercaptoethanesulphonate",
+            "2-Mercaptoethanesulphonate, Sodium",
+            "Sodium 2-Mercaptoethanesulphonate"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [],
+        "associated_with": [
+            "usp:m49500",
+            "mesh:D015080",
+            "vandf:4019477",
+            "unii:NR7O1405Q9",
+            "atc:R05CB05",
+            "atc:V03AF01",
+            "mmsl:41498",
+            "mmsl:5057",
+            "mmsl:d01411"
+        ],
+        "trade_names": [
+            "Mesna",
+            "Mesnex",
+            "Mesna Nova Plus"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def beta_alanine():
+    """Create beta-alanine drug fixture."""
+    params = {
+        "label": "beta-alanine",
+        "concept_id": "rxcui:61",
+        "aliases": [
+            "beta Alanine",
+            "3 Aminopropionic Acid",
+            "3-Aminopropionic Acid"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [],
+        "associated_with": [
+            "mesh:D015091",
+            "vandf:4028377",
+            "unii:11P2JDE17B"
+        ],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def algestone():
+    """Create algestone drug fixture."""
+    params = {
+        "label": "algestone",
+        "concept_id": "rxcui:595",
+        "aliases": [
+            "Pregn-4-ene-3,20-dione, 16,17-dihydroxy-, (16alpha)-",
+            "16 alpha,17-Dihydroxypregn-4-ene-3,20-dione",
+            "Alphasone",
+            "Dihydroxyprogesterone"
+        ],
+        "approval_rating": None,
+        "xrefs": [],
+        "associated_with": [
+            "mesh:D000523"
+        ],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def levothyroxine():
+    """Create levothyroxine drug fixture."""
+    params = {
+        "label": "levothyroxine",
+        "concept_id": "rxcui:10582",
+        "aliases": [
+            "3,5,3',5'-Tetraiodo-L-thyronine",
+            "Thyroxine",
+            "Thyroid Hormone, T4",
+            "Thyroxin",
+            "T4 Thyroid Hormone",
+            "O-(4-Hydroxy-3,5-diiodophenyl)-3,5-diiodotyrosine",
+            "3,5,3',5'-Tetraiodothyronine",
+            "O-(4-Hydroxy-3,5-diiodophenyl)-3,5-diiodo-L-tyrosine",
+            "L-T4",
+            "LT4",
+            "T4",
+            "3,3',5,5'-Tetraiodo-L-thyronine",
+            "L-Thyroxine",
+            "O-(4-Hydroxy-3,5-diidophenyl)-3,5-diiodo-L-tyrosine",
+            "4-(4-Hydroxy-3,5-diiodophenoxy)-3,5-diiodo-L-phenylalanine",
+            "Levothyroxin"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB00451"
+        ],
+        "associated_with": [
+            "vandf:4022126",
+            "mesh:D013974",
+            "mmsl:d00278",
+            "unii:Q51BO43MG4"
+        ],
+        # trade names length > 20
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def fluoxetine():
+    """Create fluoxetine drug fixture."""
+    params = {
+        "label": "fluoxetine",
+        "concept_id": "rxcui:4493",
+        "aliases": [
+            "FLUoxetine",
+            "N-Methyl-gamma-(4-(trifluoromethyl)phenoxy)"
+            "benzenepropanamine",
+            "Fluoxetin",
+            "(+-)-N-Methyl-gamma-(4-(trifluoromethyl)phenoxy)"
+            "benzenepropanamine",
+            "(+-)-N-Methyl-3-phenyl-3-((alpha,alpha,alpha-trifluoro-"
+            "P-tolyl)oxy)propylamine"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [
+            "drugbank:DB00472"
+        ],
+        "associated_with": [
+            "mesh:D005473",
+            "mmsl:17711",
+            "vandf:4019761",
+            "mmsl:d00236",
+            "atc:N06AB03",
+            "unii:01K63SUP8D"
+        ],
+        "trade_names": [
+            "Prozac",
+            "RECONCILE",
+            "Sarafem",
+            "Symbyax",
+            "Rapiflux",
+            "Selfemra"
+        ]
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def fluoxetine_hydrochloride():
+    """Create fluoxetine hydrochloride drug fixture."""
+    params = {
+        "label": "fluoxetine hydrochloride",
+        "concept_id": "rxcui:227224",
+        "aliases": [
+            "FLUoxetine hydrochloride",
+            "Fluoxetine Hydrochloride"
+        ],
+        "approval_rating": "rxnorm_prescribable",
+        "xrefs": [],
+        "associated_with": [
+            "usp:m33780",
+            "unii:I9W7N6B1KJ",
+            "mmsl:4746",
+            "vandf:4019389",
+            "mmsl:41730",
+            "mmsl:37675",
+            "mesh:D005473"
+        ],
+        "trade_names": [
+            "FLUoxetine HCl",
+            "FLUoxetine Hydrochloride",
+            "RECONCILE"
+        ]
+    }
+    return Drug(**params)
+
+
+def test_bifidobacterium_infantis(bifidobacterium_infantis, rxnorm):
+    """Test that bifidobacterium_ nfantis drug normalizes to
+    correct drug concept.
+    """
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:100213")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+    # Label Match
+    response = rxnorm.search(" Bifidobacterium infantis")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+    response = rxnorm.search("bifidobacterium infantis")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+    # Trade Name Match
+    response = rxnorm.search("ALIGN")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+    response = rxnorm.search("evivo")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+    # Xref Match
+    response = rxnorm.search("drugbank:DB14222")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+
+def test_cisplatin(cisplatin, rxnorm):
+    """Test that cisplatin drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:2555")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    # Label Match
+    response = rxnorm.search("CISPLATIN")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    # Alias Match
+    response = rxnorm.search("Cis-DDP")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = rxnorm.search("Dichlorodiammineplatinum")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = rxnorm.search("cis Platinum")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    # Trade Name Match
+    response = rxnorm.search("platinol")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    # Xref Match
+    response = rxnorm.search("drugbank:DB12117")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+
+def test_amiloride_hydrochloride(amiloride_hydrochloride, rxnorm):
+    """Test that amiloride_hydrochloride hydrochloride, anhydrous drug
+    normalizes to correct drug concept.
+    """
+    # Concept ID Match
+    response = rxnorm.search("RxcUI:142424")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride_hydrochloride)
+
+    # Label Match
+    response = rxnorm.search("amiloride hydrochloride")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride_hydrochloride)
+
+    # Trade Name Match
+    response = rxnorm.search("Midamor")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 2
+
+
+def test_amiloride(amiloride, rxnorm):
+    """Test that amiloride hydrochloride, anhydrous drug normalizes to
+    correct drug concept.
+    """
+    # Concept ID Match
+    response = rxnorm.search("RxcUI:644")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride)
+
+    # Label Match
+    response = rxnorm.search("amiloride")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride)
+
+    # Alias Match
+    response = rxnorm.search("Amyloride")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride)
+
+    response = rxnorm.search("3,5-diamino-N-carbamimidoyl-6-"
+                             "chloropyrazine-2-carboxamide")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride)
+
+    # Trade Name Match
+    response = rxnorm.search("Midamor")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 2
+
+
+def test_timolol(timolol, rxnorm):
+    """Test that timolol drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxcUI:10600")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], timolol)
+
+    # Label Match
+    response = rxnorm.search("timolol")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], timolol)
+
+    # Alias Match
+    response = rxnorm.search("(S)-1-(tert-butylamino)-3-[(4-"
+                             "morpholin-4-yl-1,2,5-thiadiazol"
+                             "-3-yl)oxy]propan-2-ol")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], timolol)
+
+    # Trade Name Match
+    response = rxnorm.search("Betimol")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], timolol)
+
+
+def test_lymphocyte(lymphocyte, rxnorm):
+    """Test that lymphocyte drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:1011")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], lymphocyte)
+
+    # Label Match
+    response = rxnorm.search("lymphocyte immune globulin, "
+                             "anti-thymocyte globulin")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], lymphocyte)
+
+    # Alias Match
+    response = rxnorm.search("Anti Thymocyte Globulin")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], lymphocyte)
+
+    response = rxnorm.search("Antithymoglobulin")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], lymphocyte)
+
+    # Trade Name Match
+    response = rxnorm.search("Thymoglobulin")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 2
+    response["records"].sort(key=lambda r: r["concept_id"])
+    compare_records(response["records"][0], lymphocyte)
+
+    response = rxnorm.search("ATGAM")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 2
+    response["records"].sort(key=lambda r: r["concept_id"])
+    compare_records(response["records"][0], lymphocyte)
+
+
+def test_aspirin(aspirin, rxnorm):
+    """Test that aspirin drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxcUI:1191")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], aspirin)
+
+    # (Trade Name) No Match
+    response = rxnorm.search("Anacin")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+
+def test_mesnan(mesna, rxnorm):
+    """Test that mesna drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:44")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], mesna)
+
+    # Label Match
+    response = rxnorm.search("mesna")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], mesna)
+
+    # Alias Match
+    response = rxnorm.search("Mesnum")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], mesna)
+
+    response = rxnorm.search("Ethanesulfonic acid, 2-mercapto-, "
+                             "monosodium salt")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], mesna)
+
+    # Trade Name Match
+    response = rxnorm.search("Mesnex")
+    assert response["match_type"] == MatchType.TRADE_NAME
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], mesna)
+
+
+def test_beta_alanine(beta_alanine, rxnorm):
+    """Test that beta_alanine drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:61")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], beta_alanine)
+
+    # Label Match
+    response = rxnorm.search("beta-alanine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], beta_alanine)
+
+    # Alias Match
+    response = rxnorm.search("3 Aminopropionic Acid")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], beta_alanine)
+
+
+def test_algestone(algestone, rxnorm):
+    """Test that algestone drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:595")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], algestone)
+
+    # Label Match
+    response = rxnorm.search("algestone")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], algestone)
+
+    # Alias Match
+    response = rxnorm.search("Pregn-4-ene-3,20-dione, 16,"
+                             "17-dihydroxy-, (16alpha)-")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], algestone)
+
+
+def test_levothyroxine(levothyroxine, rxnorm):
+    """Test that levothyroxine drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:10582")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], levothyroxine)
+
+    # Label Match
+    response = rxnorm.search("levothyroxine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], levothyroxine)
+
+    # Alias Match
+    response = rxnorm.search("Thyroxin")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], levothyroxine)
+
+    response = rxnorm.search("LT4")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], levothyroxine)
+
+    # trade name matches fail because they exceed the max # limit
+    response = rxnorm.search("Unithroid")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    response = rxnorm.search("Euthyrox")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Xref Match
+    response = rxnorm.search("DRUGBANK:DB00451")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], levothyroxine)
+
+
+def test_fluoxetine(fluoxetine, rxnorm):
+    """Test that fluoxetine drug normalizes to correct drug concept."""
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:4493")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fluoxetine)
+
+    # Label Match
+    response = rxnorm.search("fluoxetine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fluoxetine)
+
+
+def test_fluoxetine_hydrochloride(fluoxetine_hydrochloride, rxnorm):
+    """Test that fluoxetine_hydrochloride drug normalizes to correct drug
+    concept.
+    """
+    # Concept ID Match
+    response = rxnorm.search("RxCUI:227224")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fluoxetine_hydrochloride)
+
+    # Label Match
+    response = rxnorm.search("fluoxetine hydrochloride")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], fluoxetine_hydrochloride)
+
+
+def test_no_match(rxnorm):
+    """Test that a term normalizes to correct drug concept as a NO match."""
+    # Misspelled name
+    response = rxnorm.search("17-hydroxycorticosteroi")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+    # Not storing foreign synonyms
+    response = rxnorm.search("cisplatino")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+    # Wrong Namespace
+    response = rxnorm.search("rxnorm:3")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Test white space in between id
+    response = rxnorm.search("rxcui: 3")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Should not store brand name concepts as identity record
+    response = rxnorm.search("rxcui:202433")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Test empty query
+    response = rxnorm.search("")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+
+def test_brand_name_to_concept(rxnorm):
+    """Test that brand names are correctly linked to identity concept."""
+    r = rxnorm.db.therapies.query(
+        KeyConditionExpression=Key("label_and_type").eq("rxcui:1041527##rx_brand")
+    )
+    assert r["Items"][0]["concept_id"] == "rxcui:161"
+    assert r["Items"][0]["concept_id"] != "rxcui:1041527"
+
+    r = rxnorm.db.therapies.query(
+        KeyConditionExpression=Key("label_and_type").eq("rxcui:218330##rx_brand")
+    )
+    assert r["Items"][0]["concept_id"] == "rxcui:44"
+    assert r["Items"][0]["concept_id"] != "rxcui:218330"
+
+
+def test_xref_lookup(rxnorm, bifidobacterium_infantis, cisplatin, amiloride):
+    """Test that xref lookup resolves to correct concept."""
+    response = rxnorm.search("mmsl:d07347")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], bifidobacterium_infantis)
+
+    response = rxnorm.search("mesh:D002945")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = rxnorm.search("atc:C03DB01")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], amiloride)
+
+
+def test_meta_info(rxnorm):
+    """Test that the meta field is correct."""
+    response = rxnorm.fetch_meta()
+    assert response.data_license == "UMLS Metathesaurus"
+    assert response.data_license_url == \
+           "https://www.nlm.nih.gov/research/umls/rxnorm/docs/termsofservice.html"
+    assert isodate.parse_date(response.version)
+    assert response.data_url.startswith("https://download.nlm.nih.gov/umls/kss/rxnorm/")
+    assert not response.rdp_url
+    assert response.data_license_attributes == {
+        "non_commercial": False,
+        "share_alike": False,
+        "attribution": True
+    }
diff --git a/tests/unit/test_rxnorm_source.py b/tests/unit/test_rxnorm_source.py
deleted file mode 100644
index 4817d4fe..00000000
--- a/tests/unit/test_rxnorm_source.py
+++ /dev/null
@@ -1,960 +0,0 @@
-"""Test that the therapy normalizer works as intended for the RxNorm source."""
-import pytest
-from tests.conftest import compare_records
-from therapy.schemas import Drug, MatchType
-from therapy.query import QueryHandler
-from therapy.database import Database
-import os
-from boto3.dynamodb.conditions import Key
-
-
-@pytest.fixture(scope='module')
-def rxnorm():
-    """Build RxNorm normalizer test fixture."""
-    class QueryGetter:
-
-        def __init__(self):
-            self.normalizer = QueryHandler()
-            if 'THERAPY_NORM_DB_URL' in os.environ:
-                db_url = os.environ['THERAPY_NORM_DB_URL']
-            else:
-                db_url = 'http://localhost:8000'
-            self.db = Database(db_url=db_url)
-
-        def search(self, query_str):
-            resp = self.normalizer.search_sources(query_str, keyed=True,
-                                                  incl='rxnorm')
-            return resp['source_matches']['RxNorm']
-
-        def fetch_meta(self):
-            return self.normalizer._fetch_meta('RxNorm')
-
-    return QueryGetter()
-
-
-@pytest.fixture(scope='module')
-def bifidobacterium_infantis():
-    """Create bifidobacterium infantis drug fixture."""
-    params = {
-        'label': 'Bifidobacterium Infantis',
-        'concept_id': 'rxcui:100213',
-        'aliases': [
-            'bifidobacterium infantis',
-            'Bifidobacterium infantis'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB14222'
-        ],
-        'associated_with': [
-            'mmsl:d07347'
-        ],
-        'trade_names': [
-            'Align',
-            'Evivo'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def cisplatin():
-    """Create cisplatin drug fixture."""
-    params = {
-        'label': 'cisplatin',
-        'concept_id': 'rxcui:2555',
-        'aliases': [
-            'cis-Platinum',
-            'CISplatin',
-            'cis Diamminedichloroplatinum',
-            'cis Platinum',
-            'Diamminodichloride, Platinum',
-            'cis-Diamminedichloroplatinum',
-            'DDP',
-            'Dichlorodiammineplatinum',
-            'cis-Dichlorodiammineplatinum(II)',
-            'Platinum Diamminodichloride',
-            'cis-Diamminedichloroplatinum(II)',
-            'cis-diamminedichloroplatinum(II)',
-            'CDDP',
-            'Cis-DDP',
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB00515',
-            'drugbank:DB12117'
-        ],
-        'associated_with': [
-            'usp:m17910',
-            'vandf:4018139',
-            'mesh:D002945',
-            'mthspl:Q20Q21Q62J',
-            'mmsl:d00195',
-            'atc:L01XA01',
-            'mmsl:31747',
-            'mmsl:4456',
-        ],
-        'trade_names': [
-            'Cisplatin',
-            'Platinol'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def amiloride_hydrochloride():
-    """Create amiloride hydrochloride drug fixture."""
-    params = {
-        'label': 'amiloride hydrochloride',
-        'concept_id': 'rxcui:142424',
-        'aliases': [
-            'aMILoride hydrochloride',
-            'Amiloride Hydrochloride',
-            'Hydrochloride, Amiloride'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [
-            'usp:m2650',
-            'mthspl:FZJ37245UC',
-            'mmsl:2658',
-            'mesh:D000584',
-            'mmsl:4166',
-            'vandf:4017935'
-        ],
-        'trade_names': [
-            'AMILoride Hydrochloride',
-            'Midamor',
-            'Aridil',
-            'Frusemek',
-            'Midamor',
-            'Moduret',
-            'Moduretic',
-            'Zida-Co'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def amiloride():
-    """Create amiloride drug fixture."""
-    params = {
-        'label': 'amiloride',
-        'concept_id': 'rxcui:644',
-        'aliases': [
-            '3,5-diamino-N-carbamimidoyl-6-chloropyrazine-2-carboxamide',
-            'aMILoride',
-            'Amipramidin',
-            'Amipramidine',
-            'N-amidino-3,5-diamino-6-chloropyrazinecarboxamide',
-            'Amyloride'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB00594'
-        ],
-        'associated_with': [
-            'mesh:D000584',
-            'vandf:4019603',
-            'mthspl:7DZO8EB0Z3',
-            'mmsl:d00169',
-            'atc:C03DB01'
-        ],
-        'trade_names': [
-            'Midamor',
-            'Frusemek',
-            'Moduret',
-            'Aridil',
-            'Zida-Co',
-            'Amilamont',
-            'Moduretic'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def timolol():
-    """Create timolol drug fixture."""
-    params = {
-        'label': 'timolol',
-        'concept_id': 'rxcui:10600',
-        'aliases': [
-            '(S)-1-((1,1-Dimethylethyl)amino)-3-((4-(4-morpholinyl)'
-            '-1,2,5-thiadazol-3-yl)oxy)-2-propanol',
-            '2-Propanol, 1-((1,1-dimethylethyl)amino)-3-((4-(4-morpholi'
-            'nyl)-1,2,5-thiadiazol-3-yl)oxy)-, (S)-',
-            '(S)-1-(tert-butylamino)-3-[(4-morpholin-4-yl-1,2,5-thiadiazol'
-            '-3-yl)oxy]propan-2-ol'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB00373'
-        ],
-        'associated_with': [
-            'vandf:4019949',
-            'mthspl:817W3C6175',
-            'mesh:D013999',
-            'mmsl:d00139',
-            'atc:C07AA06',
-            'atc:S01ED01'
-        ],
-        'trade_names': [
-            'Betimol',
-            'Timoptic',
-            'Istalol',
-            'Combigan',
-            'Cosopt',
-            'Timoptol LA',
-            'Blocadren',
-            'Timolide',
-            'Betim',
-            'Glau-Opt',
-            'Glaucol',
-            'Timoptol'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def lymphocyte():
-    """Create lymphocyte immune globulin, anti-thymocyte globulin drug
-    fixture.
-    """
-    params = {
-        'label': 'lymphocyte immune globulin, anti-thymocyte globulin',
-        'concept_id': 'rxcui:1011',
-        'aliases': [
-            'Lymphocyte Immune Globulin, Anti-Thymocyte Globulin',
-            'Anti-Thymocyte Globulin',
-            'Antithymocyte Globulin',
-            'Antithymoglobulin',
-            'Lymphocyte Immune Globulin, Anti Thymocyte Globulin',
-            'Globulins, Antithymocyte',
-            'Antithymocyte Globulins',
-            'Antithymoglobulins',
-            'Anti-Thymocyte Globulins',
-            'Globulins, Anti-Thymocyte',
-            'Globulin, Antithymocyte',
-            'Anti Thymocyte Globulin',
-            'Globulin, Anti-Thymocyte',
-            'lymphocyte immune globulin, anti-thy (obs)'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [
-            'mesh:D000961',
-            'vandf:4022194',
-            'vandf:4018097',
-            'mmsl:d01141',
-            'mmsl:5001'
-        ],
-        'trade_names': [
-            'ATGAM',
-            'Thymoglobulin'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def aspirin():
-    """Create aspirin drug fixture."""
-    params = {
-        'label': 'aspirin',
-        'concept_id': 'rxcui:1191',
-        'aliases': [
-            'Salicylic acid acetate',
-            'Aspirin',
-            'Acid, Acetylsalicylic',
-            '2-(Acetyloxy)benzoic Acid',
-            '2-Acetoxybenzenecarboxylic acid',
-            'ASA',
-            'O-acetylsalicylic acid',
-            '2-Acetoxybenzoic acid',
-            'o-acetoxybenzoic acid',
-            'o-carboxyphenyl acetate',
-            'Acetylsalicylic Acid'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB00945'
-        ],
-        'associated_with': [
-            'usp:m6240',
-            'vandf:4017536',
-            'mmsl:34512',
-            'mthspl:R16CO5Y76E',
-            'mmsl:244',
-            'mesh:D001241',
-            'mmsl:4223',
-            'mmsl:d00170',
-            'atc:A01AD05',
-            'atc:B01AC06',
-            'atc:N02BA01'
-        ],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def mesna():
-    """Create mesna drug fixture."""
-    params = {
-        'label': 'mesna',
-        'concept_id': 'rxcui:44',
-        'aliases': [
-            'Mesnum',
-            'Ethanesulfonic acid, 2-mercapto-, monosodium salt',
-            'Sodium 2-Mercaptoethanesulphonate',
-            '2-Mercaptoethanesulphonate, Sodium',
-            'Sodium 2-Mercaptoethanesulphonate'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [
-            'usp:m49500',
-            'mesh:D015080',
-            'vandf:4019477',
-            'mthspl:NR7O1405Q9',
-            'atc:R05CB05',
-            'atc:V03AF01',
-            'mmsl:41498',
-            'mmsl:5057',
-            'mmsl:d01411'
-        ],
-        'trade_names': [
-            'Mesna',
-            'Mesnex',
-            'Mesna Nova Plus'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def beta_alanine():
-    """Create beta-alanine drug fixture."""
-    params = {
-        'label': 'beta-alanine',
-        'concept_id': 'rxcui:61',
-        'aliases': [
-            'beta Alanine',
-            '3 Aminopropionic Acid',
-            '3-Aminopropionic Acid'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [
-            'mesh:D015091',
-            'vandf:4028377',
-            'mthspl:11P2JDE17B'
-        ],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def algestone():
-    """Create algestone drug fixture."""
-    params = {
-        'label': 'algestone',
-        'concept_id': 'rxcui:595',
-        'aliases': [
-            'Pregn-4-ene-3,20-dione, 16,17-dihydroxy-, (16alpha)-',
-            '16 alpha,17-Dihydroxypregn-4-ene-3,20-dione',
-            'Alphasone',
-            'Dihydroxyprogesterone'
-        ],
-        'approval_status': None,
-        'xrefs': [],
-        'associated_with': [
-            'mesh:D000523'
-        ],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def levothyroxine():
-    """Create levothyroxine drug fixture."""
-    params = {
-        'label': 'levothyroxine',
-        'concept_id': 'rxcui:10582',
-        'aliases': [
-            '3,5,3\',5\'-Tetraiodo-L-thyronine',
-            'Thyroxine',
-            'thyroxine',
-            'Thyroid Hormone, T4',
-            'Thyroxin',
-            'T4 Thyroid Hormone',
-            'O-(4-Hydroxy-3,5-diiodophenyl)-3,5-diiodotyrosine',
-            '3,5,3\',5\'-Tetraiodothyronine',
-            'O-(4-Hydroxy-3,5-diiodophenyl)-3,5-diiodo-L-tyrosine',
-            'L-T4',
-            'LT4',
-            'T4',
-            '3,3\',5,5\'-Tetraiodo-L-thyronine',
-            'L-Thyroxine',
-            'O-(4-Hydroxy-3,5-diidophenyl)-3,5-diiodo-L-tyrosine',
-            '4-(4-Hydroxy-3,5-diiodophenoxy)-3,5-diiodo-L-phenylalanine',
-            'Levothyroxin'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB00451'
-        ],
-        'associated_with': [
-            'vandf:4022126',
-            'mesh:D013974',
-            'mmsl:d00278',
-            'mthspl:Q51BO43MG4'
-        ],
-        'trade_names': [
-            'Eltroxin',
-            'Estre',
-            'Euthyrox',
-            'Leventa',
-            'Levo-T',
-            'Levocrine',
-            'Levotabs',
-            'Levothroid',
-            'Levothyroid',
-            'Levoxyl',
-            'Novothyrox',
-            'SOLOXINE',
-            'Synthroid',
-            'Thyro-Tabs',
-            'ThyroKare',
-            'ThyroMed',
-            'Thyrolar',
-            'Thyrox',
-            'Tirosint',
-            'Unithroid'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def fluoxetine():
-    """Create fluoxetine drug fixture."""
-    params = {
-        'label': 'fluoxetine',
-        'concept_id': 'rxcui:4493',
-        'aliases': [
-            'FLUoxetine',
-            'N-Methyl-gamma-(4-(trifluoromethyl)phenoxy)'
-            'benzenepropanamine',
-            'Fluoxetin',
-            '(+-)-N-Methyl-gamma-(4-(trifluoromethyl)phenoxy)'
-            'benzenepropanamine',
-            '(+-)-N-Methyl-3-phenyl-3-((alpha,alpha,alpha-trifluoro-'
-            'P-tolyl)oxy)propylamine'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [
-            'drugbank:DB00472'
-        ],
-        'associated_with': [
-            'mesh:D005473',
-            'mmsl:17711',
-            'vandf:4019761',
-            'mmsl:d00236',
-            'atc:N06AB03',
-            'mthspl:01K63SUP8D'
-        ],
-        'trade_names': [
-            'Prozac',
-            'RECONCILE',
-            'Sarafem',
-            'Symbyax',
-            'Rapiflux',
-            'Selfemra'
-        ]
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def fluoxetine_hydrochloride():
-    """Create fluoxetine hydrochloride drug fixture."""
-    params = {
-        'label': 'fluoxetine hydrochloride',
-        'concept_id': 'rxcui:227224',
-        'aliases': [
-            'FLUoxetine hydrochloride',
-            'Fluoxetine Hydrochloride'
-        ],
-        'approval_status': 'approved',
-        'xrefs': [],
-        'associated_with': [
-            'usp:m33780',
-            'mthspl:I9W7N6B1KJ',
-            'mmsl:4746',
-            'vandf:4019389',
-            'mmsl:41730',
-            'mmsl:37675',
-            'mesh:D005473'
-        ],
-        'trade_names': [
-            'FLUoxetine HCl',
-            'FLUoxetine Hydrochloride',
-            'RECONCILE'
-        ]
-    }
-    return Drug(**params)
-
-
-def test_bifidobacterium_infantis(bifidobacterium_infantis, rxnorm):
-    """Test that bifidobacterium_ nfantis drug normalizes to
-    correct drug concept.
-    """
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:100213')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-    # Label Match
-    response = rxnorm.search(' Bifidobacterium infantis')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-    response = rxnorm.search('bifidobacterium infantis')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-    # Trade Name Match
-    response = rxnorm.search('ALIGN')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-    response = rxnorm.search('evivo')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-    # Xref Match
-    response = rxnorm.search('drugbank:DB14222')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-
-def test_cisplatin(cisplatin, rxnorm):
-    """Test that cisplatin drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:2555')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    # Label Match
-    response = rxnorm.search('CISPLATIN')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    # Alias Match
-    response = rxnorm.search('Cis-DDP')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = rxnorm.search('Dichlorodiammineplatinum')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = rxnorm.search('cis Platinum')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    # Trade Name Match
-    response = rxnorm.search('platinol')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    # Xref Match
-    response = rxnorm.search('drugbank:DB12117')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-
-def test_amiloride_hydrochloride(amiloride_hydrochloride, rxnorm):
-    """Test that amiloride_hydrochloride hydrochloride, anhydrous drug
-    normalizes to correct drug concept.
-    """
-    # Concept ID Match
-    response = rxnorm.search('RxcUI:142424')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride_hydrochloride)
-
-    # Label Match
-    response = rxnorm.search('amiloride hydrochloride')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride_hydrochloride)
-
-    # Trade Name Match
-    response = rxnorm.search('Midamor')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 2
-
-
-def test_amiloride(amiloride, rxnorm):
-    """Test that amiloride hydrochloride, anhydrous drug normalizes to
-    correct drug concept.
-    """
-    # Concept ID Match
-    response = rxnorm.search('RxcUI:644')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride)
-
-    # Label Match
-    response = rxnorm.search('amiloride')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride)
-
-    # Alias Match
-    response = rxnorm.search('Amyloride')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride)
-
-    response = rxnorm.search('3,5-diamino-N-carbamimidoyl-6-'
-                             'chloropyrazine-2-carboxamide')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride)
-
-    # Trade Name Match
-    response = rxnorm.search('Midamor')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 2
-
-
-def test_timolol(timolol, rxnorm):
-    """Test that timolol drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxcUI:10600')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], timolol)
-
-    # Label Match
-    response = rxnorm.search('timolol')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], timolol)
-
-    # Alias Match
-    response = rxnorm.search('(S)-1-(tert-butylamino)-3-[(4-'
-                             'morpholin-4-yl-1,2,5-thiadiazol'
-                             '-3-yl)oxy]propan-2-ol')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], timolol)
-
-    # Trade Name Match
-    response = rxnorm.search('Betimol')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], timolol)
-
-
-def test_lymphocyte(lymphocyte, rxnorm):
-    """Test that lymphocyte drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:1011')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], lymphocyte)
-
-    # Label Match
-    response = rxnorm.search('lymphocyte immune globulin, '
-                             'anti-thymocyte globulin')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], lymphocyte)
-
-    # Alias Match
-    response = rxnorm.search('Anti Thymocyte Globulin')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], lymphocyte)
-
-    response = rxnorm.search('Antithymoglobulin')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], lymphocyte)
-
-    # Trade Name Match
-    response = rxnorm.search('Thymoglobulin')
-    assert response['match_type'] == MatchType.TRADE_NAME
-
-    response = rxnorm.search('ATGAM')
-    assert response['match_type'] == MatchType.TRADE_NAME
-
-
-def test_aspirin(aspirin, rxnorm):
-    """Test that aspirin drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxcUI:1191')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-
-    # (Trade Name) No Match
-    response = rxnorm.search('Anacin')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-
-def test_mesnan(mesna, rxnorm):
-    """Test that mesna drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:44')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], mesna)
-
-    # Label Match
-    response = rxnorm.search('mesna')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], mesna)
-
-    # Alias Match
-    response = rxnorm.search('Mesnum')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], mesna)
-
-    response = rxnorm.search('Ethanesulfonic acid, 2-mercapto-, '
-                             'monosodium salt')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], mesna)
-
-    # Trade Name Match
-    response = rxnorm.search('Mesnex')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], mesna)
-
-
-def test_beta_alanine(beta_alanine, rxnorm):
-    """Test that beta_alanine drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:61')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], beta_alanine)
-
-    # Label Match
-    response = rxnorm.search('beta-alanine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], beta_alanine)
-
-    # Alias Match
-    response = rxnorm.search('3 Aminopropionic Acid')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], beta_alanine)
-
-
-def test_algestone(algestone, rxnorm):
-    """Test that algestone drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:595')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], algestone)
-
-    # Label Match
-    response = rxnorm.search('algestone')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], algestone)
-
-    # Alias Match
-    response = rxnorm.search('Pregn-4-ene-3,20-dione, 16,'
-                             '17-dihydroxy-, (16alpha)-')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], algestone)
-
-
-def test_levothyroxine(levothyroxine, rxnorm):
-    """Test that levothyroxine drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:10582')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-    # Label Match
-    response = rxnorm.search('levothyroxine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-    # Alias Match
-    response = rxnorm.search('Thyroxin')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-    response = rxnorm.search('LT4')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-    # Trade Name Match
-    response = rxnorm.search('Unithroid')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-    response = rxnorm.search('Euthyrox')
-    assert response['match_type'] == MatchType.TRADE_NAME
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-    # Xref Match
-    response = rxnorm.search('DRUGBANK:DB00451')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], levothyroxine)
-
-
-def test_fluoxetine(fluoxetine, rxnorm):
-    """Test that fluoxetine drug normalizes to correct drug concept."""
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:4493')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], fluoxetine)
-
-    # Label Match
-    response = rxnorm.search('fluoxetine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], fluoxetine)
-
-
-def test_fluoxetine_hydrochloride(fluoxetine_hydrochloride, rxnorm):
-    """Test that fluoxetine_hydrochloride drug normalizes to correct drug
-    concept.
-    """
-    # Concept ID Match
-    response = rxnorm.search('RxCUI:227224')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], fluoxetine_hydrochloride)
-
-    # Label Match
-    response = rxnorm.search('fluoxetine hydrochloride')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], fluoxetine_hydrochloride)
-
-
-def test_no_match(rxnorm):
-    """Test that a term normalizes to correct drug concept as a NO match."""
-    # Misspelled name
-    response = rxnorm.search('17-hydroxycorticosteroi')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-    # Not storing foreign synonyms
-    response = rxnorm.search('cisplatino')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-    # Wrong Namespace
-    response = rxnorm.search('rxnorm:3')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    # Test white space in between id
-    response = rxnorm.search('rxcui: 3')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    # Should not store brand name concepts as identity record
-    response = rxnorm.search('rxcui:202433')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    # Test empty query
-    response = rxnorm.search('')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-
-def test_brand_name_to_concept(rxnorm):
-    """Test that brand names are correctly linked to identity concept."""
-    r = rxnorm.db.therapies.query(
-        KeyConditionExpression=Key('label_and_type').eq(
-            'rxcui:1041527##rx_brand')
-    )
-    assert r['Items'][0]['concept_id'] == 'rxcui:161'
-    assert r['Items'][0]['concept_id'] != 'rxcui:1041527'
-
-    r = rxnorm.db.therapies.query(
-        KeyConditionExpression=Key('label_and_type').eq(
-            'rxcui:218330##rx_brand')
-    )
-    assert r['Items'][0]['concept_id'] == 'rxcui:44'
-    assert r['Items'][0]['concept_id'] != 'rxcui:218330'
-
-
-def test_xref_lookup(rxnorm, bifidobacterium_infantis, cisplatin, amiloride):
-    """Test that xref lookup resolves to correct concept."""
-    response = rxnorm.search('mmsl:d07347')
-    assert response['match_type'] == MatchType.ASSOCIATED_WITH
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], bifidobacterium_infantis)
-
-    response = rxnorm.search('mesh:D002945')
-    assert response['match_type'] == MatchType.ASSOCIATED_WITH
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = rxnorm.search('atc:C03DB01')
-    assert response['match_type'] == MatchType.ASSOCIATED_WITH
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], amiloride)
-
-
-def test_meta_info(rxnorm):
-    """Test that the meta field is correct."""
-    response = rxnorm.fetch_meta()
-    assert response.data_license == 'UMLS Metathesaurus'
-    assert response.data_license_url == \
-           'https://www.nlm.nih.gov/research/umls/rxnorm/docs/' \
-           'termsofservice.html'
-    assert response.version == '20210104'
-    assert response.data_url == \
-           'https://www.nlm.nih.gov/research/umls/rxnorm/docs/rxnormfiles.html'
-    assert not response.rdp_url
-    assert response.data_license_attributes == {
-        "non_commercial": False,
-        "share_alike": False,
-        "attribution": True
-    }
diff --git a/tests/unit/test_wikidata.py b/tests/unit/test_wikidata.py
new file mode 100644
index 00000000..dc6e6e63
--- /dev/null
+++ b/tests/unit/test_wikidata.py
@@ -0,0 +1,348 @@
+"""Test that the therapy normalizer works as intended for the
+Wikidata source.
+"""
+import pytest
+import isodate
+
+from therapy.schemas import Drug, MatchType
+from therapy.query import QueryHandler
+from tests.conftest import compare_records
+
+
+@pytest.fixture(scope="module")
+def wikidata():
+    """Build Wikidata normalizer test fixture."""
+    class QueryGetter:
+
+        def __init__(self):
+            self.query_handler = QueryHandler()
+
+        def search(self, query_str):
+            resp = self.query_handler.search_sources(query_str, keyed=True,
+                                                     incl="wikidata")
+            return resp["source_matches"]["Wikidata"]
+    return QueryGetter()
+
+
+@pytest.fixture(scope="module")
+def cisplatin():
+    """Create a cisplatin drug fixture."""
+    params = {
+        "label": "cisplatin",
+        "concept_id": "wikidata:Q412415",
+        "aliases": [
+            "CDDP",
+            "Cis-DDP",
+            "CIS-DDP",
+            "cis-diamminedichloroplatinum(II)",
+            "Platinol",
+            "Platinol-AQ"
+        ],
+        "approval_rating": None,
+        "xrefs": [
+            "chembl:CHEMBL11359",
+            "drugbank:DB00515",
+            "rxcui:2555",
+            "chemidplus:15663-27-1",
+        ],
+        "associated_with": [
+            "pubchem.compound:5702198"
+        ],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+@pytest.fixture(scope="module")
+def interferon_alfacon_1():
+    """Create an Interferon alfacon-1 drug fixture."""
+    params = {
+        "label": "Interferon alfacon-1",
+        "concept_id": "wikidata:Q15353101",
+        "aliases": [
+            "CIFN",
+            "consensus interferon",
+            "IFN Alfacon-1",
+            "Interferon Consensus, Methionyl",
+            "methionyl interferon consensus",
+            "methionyl-interferon-consensus",
+            "rCon-IFN",
+            "Recombinant Consensus Interferon",
+            "Recombinant methionyl human consensus interferon"
+        ],
+        "approval_rating": None,
+        "xrefs": [
+            "chembl:CHEMBL1201557",
+            "drugbank:DB00069",
+            "rxcui:59744",
+            "chemidplus:118390-30-0"
+        ],
+        "associated_with": [],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+# Test drug that has > 20 aliases
+@pytest.fixture(scope="module")
+def d_methamphetamine():
+    """Create a D-methamphetamine drug fixture."""
+    params = {
+        "label": "D-methamphetamine",
+        "concept_id": "wikidata:Q191924",
+        "aliases": [],
+        "approval_rating": None,
+        "xrefs": [
+            "chembl:CHEMBL1201201",
+            "drugbank:DB01577",
+            "rxcui:6816",
+            "chemidplus:537-46-2",
+        ],
+        "associated_with": [
+            "pubchem.compound:10836",
+        ],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+# Test removing duplicate (case sensitive) aliases
+@pytest.fixture(scope="module")
+def atropine():
+    """Create an atropine drug fixture."""
+    params = {
+        "label": "atropine",
+        "concept_id": "wikidata:Q26272",
+        "aliases": [
+            "dl-Hyoscyamine",
+            "dl-tropyltropate",
+            "Mydriasine",
+            "Tropine tropate",
+            "(±)-atropine",
+            "(±)-hyoscyamine",
+            "(+-)-Atropine",
+            "(+-)-Hyoscyamine",
+            "(+,-)-Tropyl tropate",
+            "(3-Endo)-8-methyl-8-azabicyclo[3.2.1]oct-3-yl tropate",
+            "[(1S,5R)-8-Methyl-8-azabicyclo[3.2.1]oct-3-yl] "
+            "3-hydroxy-2-phenyl-propanoate",
+            "8-Methyl-8-azabicyclo[3.2.1]oct-3-yl "
+            "3-hydroxy-2-phenylpropanoate",
+            "8-Methyl-8-azabicyclo[3.2.1]oct-3-yl tropate"
+        ],
+        "approval_rating": None,
+        "xrefs": [
+            "drugbank:DB00572",
+            "chemidplus:51-55-8",
+            "rxcui:1223"
+        ],
+        "associated_with": [
+            "pubchem.compound:174174",
+        ],
+        "trade_names": []
+    }
+    return Drug(**params)
+
+
+def test_cisplatin(cisplatin, wikidata):
+    """Test that cisplatin drug normalizes to correct drug concept."""
+    response = wikidata.search("wikidata:Q412415")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("wiKIdata:Q412415")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("wiKIdata:q412415")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("cisplatin")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("Cisplatin")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("CDDP")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("cddp")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("chembl:CHEMBL11359")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("drugbank:DB00515")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("rxcui:2555")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("chemidplus:15663-27-1")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+    response = wikidata.search("pubchem.compound:5702198")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], cisplatin)
+
+
+def test_atropine(atropine, wikidata):
+    """Test that atropine drug normalizes to correct drug concept."""
+    response = wikidata.search("wikidata:Q26272")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("wiKIdata:Q26272")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("wiKIdata:q26272")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("atropine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("Atropine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("Mydriasine")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("(±)-Hyoscyamine")
+    assert response["match_type"] == MatchType.ALIAS
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("chemidplus:51-55-8")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+    response = wikidata.search("pubchem.compound:174174")
+    assert response["match_type"] == MatchType.ASSOCIATED_WITH
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], atropine)
+
+
+def test_interferon_alfacon_1(interferon_alfacon_1, wikidata):
+    """Test that interferon alfacon-1 drug normalizes to correct concept"""
+    response = wikidata.search("wikidata:Q15353101")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], interferon_alfacon_1)
+
+    response = wikidata.search("wiKIdata:Q15353101")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], interferon_alfacon_1)
+
+    response = wikidata.search("wiKIdata:q15353101")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], interferon_alfacon_1)
+
+    response = wikidata.search("Interferon alfacon-1")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], interferon_alfacon_1)
+
+    response = wikidata.search("Interferon Alfacon-1")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], interferon_alfacon_1)
+
+
+def test_d_methamphetamine(d_methamphetamine, wikidata):
+    """Test that d_methamphetamine drug normalizes to correct concept."""
+    response = wikidata.search("wikidata:Q191924")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], d_methamphetamine)
+
+    response = wikidata.search("wiKIdata:Q191924")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], d_methamphetamine)
+
+    response = wikidata.search("wiKIdata:q191924")
+    assert response["match_type"] == MatchType.CONCEPT_ID
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], d_methamphetamine)
+
+    response = wikidata.search("D-methamphetamine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], d_methamphetamine)
+
+    response = wikidata.search("d-methamphetamine")
+    assert response["match_type"] == MatchType.LABEL
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], d_methamphetamine)
+
+    response = wikidata.search("rxcui:6816")
+    assert response["match_type"] == MatchType.XREF
+    assert len(response["records"]) == 1
+    compare_records(response["records"][0], d_methamphetamine)
+
+
+def test_case_no_match(wikidata):
+    """Test that a term normalizes to NO match."""
+    response = wikidata.search("cisplati")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+    # Test white space in between label
+    response = wikidata.search("Interferon alfacon - 1")
+    assert response["match_type"] == MatchType.NO_MATCH
+
+    # Test empty query
+    response = wikidata.search("")
+    assert response["match_type"] == MatchType.NO_MATCH
+    assert len(response["records"]) == 0
+
+
+def test_meta_info(wikidata):
+    """Test that the meta field is correct."""
+    response = wikidata.search("cisplatin")
+    assert response["source_meta_"]["data_license"] == "CC0 1.0"
+    assert response["source_meta_"]["data_license_url"] == \
+           "https://creativecommons.org/publicdomain/zero/1.0/"
+    assert isodate.parse_date(response["source_meta_"]["version"])
+    assert response["source_meta_"]["data_url"] is None
+    assert not response["source_meta_"]["rdp_url"]
+    assert response["source_meta_"]["data_license_attributes"] == {
+        "non_commercial": False,
+        "share_alike": False,
+        "attribution": False
+    }
diff --git a/tests/unit/test_wikidata_source.py b/tests/unit/test_wikidata_source.py
deleted file mode 100644
index c85b8d03..00000000
--- a/tests/unit/test_wikidata_source.py
+++ /dev/null
@@ -1,388 +0,0 @@
-"""Test that the therapy normalizer works as intended for the
-Wikidata source.
-"""
-from datetime import datetime
-
-import pytest
-from therapy.schemas import Drug, MatchType
-from therapy.query import QueryHandler
-from tests.conftest import compare_records
-
-
-@pytest.fixture(scope='module')
-def wikidata():
-    """Build Wikidata normalizer test fixture."""
-    class QueryGetter:
-
-        def __init__(self):
-            self.query_handler = QueryHandler()
-
-        def search(self, query_str):
-            resp = self.query_handler.search_sources(query_str, keyed=True,
-                                                     incl='wikidata')
-            return resp['source_matches']['Wikidata']
-    return QueryGetter()
-
-
-@pytest.fixture(scope='module')
-def cisplatin():
-    """Create a cisplatin drug fixture."""
-    params = {
-        'label': 'cisplatin',
-        'concept_id': 'wikidata:Q412415',
-        'aliases': [
-            'CDDP',
-            'Cis-DDP',
-            'CIS-DDP',
-            'cis-diamminedichloroplatinum(II)',
-            'Platinol',
-            'Platinol-AQ'
-        ],
-        'approval_status': None,
-        'xrefs': [
-            'chembl:CHEMBL11359',
-            'drugbank:DB00515',
-            'rxcui:2555',
-            'chemidplus:15663-27-1',
-        ],
-        'associated_with': [
-            'pubchem.compound:5702198'
-        ],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def platinol():
-    """Create a platinol drug fixture."""
-    params = {
-        'label': 'Platinol',
-        'concept_id': 'wikidata:Q47522001',
-        'aliases': [],
-        'trade_names': [],
-        'approval_status': None,
-        'xrefs': ['rxcui:202856'],
-        'associated_with': []
-    }
-    return Drug(**params)
-
-
-@pytest.fixture(scope='module')
-def interferon_alfacon_1():
-    """Create an Interferon alfacon-1 drug fixture."""
-    params = {
-        'label': 'Interferon alfacon-1',
-        'concept_id': 'wikidata:Q15353101',
-        'aliases': [
-            'CIFN',
-            'consensus interferon',
-            'IFN Alfacon-1',
-            'Interferon Consensus, Methionyl',
-            'methionyl interferon consensus',
-            'methionyl-interferon-consensus',
-            'rCon-IFN',
-            'Recombinant Consensus Interferon',
-            'Recombinant methionyl human consensus interferon'
-        ],
-        'approval_status': None,
-        'xrefs': [
-            'chembl:CHEMBL1201557',
-            'drugbank:DB00069',
-            'rxcui:59744',
-            'chemidplus:118390-30-0'
-        ],
-        'associated_with': [],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-# Test drug that has > 20 aliases
-@pytest.fixture(scope='module')
-def d_methamphetamine():
-    """Create a D-methamphetamine drug fixture."""
-    params = {
-        'label': 'D-methamphetamine',
-        'concept_id': 'wikidata:Q191924',
-        'aliases': [],
-        'approval_status': None,
-        'xrefs': [
-            'chembl:CHEMBL1201201',
-            'drugbank:DB01577',
-            'rxcui:6816',
-            'chemidplus:537-46-2',
-        ],
-        'associated_with': [
-            'pubchem.compound:10836',
-        ],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-# Test removing duplicate (case sensitive) aliases
-@pytest.fixture(scope='module')
-def atropine():
-    """Create an atropine drug fixture."""
-    params = {
-        'label': 'atropine',
-        'concept_id': 'wikidata:Q26272',
-        'aliases': [
-            'dl-Hyoscyamine',
-            'dl-tropyltropate',
-            'Mydriasine',
-            'Tropine tropate',
-            '(±)-atropine',
-            '(±)-hyoscyamine',
-            '(+-)-Atropine',
-            '(+-)-Hyoscyamine',
-            '(+,-)-Tropyl tropate',
-            '(3-Endo)-8-methyl-8-azabicyclo[3.2.1]oct-3-yl tropate',
-            '[(1S,5R)-8-Methyl-8-azabicyclo[3.2.1]oct-3-yl] '
-            '3-hydroxy-2-phenyl-propanoate',
-            '8-Methyl-8-azabicyclo[3.2.1]oct-3-yl '
-            '3-hydroxy-2-phenylpropanoate',
-            '8-Methyl-8-azabicyclo[3.2.1]oct-3-yl tropate'
-        ],
-        'approval_status': None,
-        'xrefs': [
-            'drugbank:DB00572',
-            'chemidplus:51-55-8',
-            'rxcui:1223'
-        ],
-        'associated_with': [
-            'pubchem.compound:174174',
-        ],
-        'trade_names': []
-    }
-    return Drug(**params)
-
-
-def test_cisplatin(cisplatin, wikidata):
-    """Test that cisplatin drug normalizes to correct drug concept."""
-    response = wikidata.search('wikidata:Q412415')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('wiKIdata:Q412415')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('wiKIdata:q412415')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('Q412415')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('cisplatin')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('Cisplatin')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('CDDP')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('cddp')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('chembl:CHEMBL11359')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('drugbank:DB00515')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('rxcui:2555')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('chemidplus:15663-27-1')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-    response = wikidata.search('pubchem.compound:5702198')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], cisplatin)
-
-
-def test_atropine(atropine, wikidata):
-    """Test that atropine drug normalizes to correct drug concept."""
-    response = wikidata.search('wikidata:Q26272')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('wiKIdata:Q26272')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('wiKIdata:q26272')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('Q26272')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('atropine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('Atropine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('Mydriasine')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('(±)-Hyoscyamine')
-    assert response['match_type'] == MatchType.ALIAS
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('chemidplus:51-55-8')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-    response = wikidata.search('pubchem.compound:174174')
-    assert response['match_type'] == MatchType.ASSOCIATED_WITH
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], atropine)
-
-
-def test_interferon_alfacon_1(interferon_alfacon_1, wikidata):
-    """Test that interferon alfacon-1 drug normalizes to correct concept"""
-    response = wikidata.search('wikidata:Q15353101')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-    response = wikidata.search('wiKIdata:Q15353101')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-    response = wikidata.search('wiKIdata:q15353101')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-    response = wikidata.search('Q15353101')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-    response = wikidata.search('q15353101')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-    response = wikidata.search('Interferon alfacon-1')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-    response = wikidata.search('Interferon Alfacon-1')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], interferon_alfacon_1)
-
-
-def test_d_methamphetamine(d_methamphetamine, wikidata):
-    """Test that d_methamphetamine drug normalizes to correct concept."""
-    response = wikidata.search('wikidata:Q191924')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-    response = wikidata.search('wiKIdata:Q191924')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-    response = wikidata.search('wiKIdata:q191924')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-    response = wikidata.search('Q191924')
-    assert response['match_type'] == MatchType.CONCEPT_ID
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-    response = wikidata.search('D-methamphetamine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-    response = wikidata.search('d-methamphetamine')
-    assert response['match_type'] == MatchType.LABEL
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-    response = wikidata.search('rxcui:6816')
-    assert response['match_type'] == MatchType.XREF
-    assert len(response['records']) == 1
-    compare_records(response['records'][0], d_methamphetamine)
-
-
-def test_case_no_match(wikidata):
-    """Test that a term normalizes to NO match."""
-    response = wikidata.search('cisplati')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-    # Test white space in between label
-    response = wikidata.search('Interferon alfacon - 1')
-    assert response['match_type'] == MatchType.NO_MATCH
-
-    # Test empty query
-    response = wikidata.search('')
-    assert response['match_type'] == MatchType.NO_MATCH
-    assert len(response['records']) == 0
-
-
-def test_meta_info(wikidata):
-    """Test that the meta field is correct."""
-    response = wikidata.search('cisplatin')
-    assert response['source_meta_']['data_license'] == 'CC0 1.0'
-    assert response['source_meta_']['data_license_url'] == \
-           'https://creativecommons.org/publicdomain/zero/1.0/'
-    assert datetime.strptime(response['source_meta_']['version'], "%Y%m%d")
-    assert response['source_meta_']['data_url'] is None
-    assert not response['source_meta_']['rdp_url']
-    assert response['source_meta_']['data_license_attributes'] == {
-        "non_commercial": False,
-        "share_alike": False,
-        "attribution": False
-    }