Files
stack/tests/aco/test_load_bcda.py
kert d08401dc53 feat(bcda): BCDA v3 — client, store adoption and cancel retries, dictionary-driven v3 flatteners, streaming multi-part load
Client defaults to /api/v3; _typeFilter on start_export/export with
type_filter_tag() for the System-Type tag; jobs() sends the bulk-data headers
BCDA requires (Prefer: respond-async, Accept: application/fhir+json — the old
call 400'd on v2 and v3) and unwraps the Task Bundle; completed_jobs() lists
reusable exports; every non-2xx raises BcdaHttpError carrying the
OperationOutcome diagnostics (still an httpx.HTTPStatusError); a poll that
hits 404 'Job has been cancelled' raises JobCancelledError; /_version and
/_health.

Store.adopt(client, job_url) records and downloads a job this store did not
start (completed jobs stay downloadable 24 h and the public sandbox entity is
shared); export(cancel_retries=N) restarts after server-side cancellation,
recording each 'cancelled' transition.

bcda.express.v3 flattens C4BB 2.2.0 resources into the existing bcda.* tables,
table-driven from the v3 Data Dictionary: v3 moved every BlueButton code off
resources/variables/<name> onto fhir/StructureDefinition|CodeSystem/<CODE> and
consolidated amounts into adjudication/total/item.adjudication, so the v2
suffix match returned NULL for almost everything. Dispatch is per resource
(is_v3); v2 payloads are unchanged. Identity follows the migration guide:
bene_id = Patient.id (BENE_SK), current MBI = us-mbi identifier without
period.end, clm_id = C4BB 'uc', clm_cntl_num pairs SharedSystem with NCH.
Race crosswalked from US Core OMB codes; Coverage is one row per plan+period
(Part A/B/C/D + Dual) with v3-native columns; EOB gains System-Type /
Final-Action / EOB-TYPE, IDR dates, header totals and PPS/IME/DSH amounts;
items gain NDC quantity, revenue-centre indicators, ANSI codes, Part D/DME
amounts.

flatten_export streams <Type>.N.ndjson parts to Parquet with model-derived
Arrow schemas (a Large-entity EOB export is 192 files / 2.2 GB); load_bcda()
gathers the newest export dir per resource type (a v3 pull is three
single-type jobs). cclf.stg_beneficiary_demographics prefers the Part A row
and joins the Dual row; eligibility reads coalesce(dual_01, dual_stus_cd).

bcda package at 100% line coverage; tests run on CMS's official v3 samples.

refs #1139
2026-10-25 13:08:13 -04:00

125 lines
4.1 KiB
Python

"""Tests for BCDA data loading."""
from __future__ import annotations
from pathlib import Path
import pytest
from aco.load.bcda import _find_latest_export
class TestFindLatestExport:
def test_no_exports_dir_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="No exports directory"):
_find_latest_export(tmp_path)
def test_empty_exports_dir_raises(self, tmp_path: Path) -> None:
(tmp_path / "exports").mkdir()
with pytest.raises(FileNotFoundError, match="No export directories"):
_find_latest_export(tmp_path)
def test_finds_latest(self, tmp_path: Path) -> None:
import os
exports = tmp_path / "exports"
exports.mkdir()
(exports / "old_job").mkdir()
(exports / "new_job").mkdir()
# Set old_job mtime to past so new_job is clearly newer
os.utime(exports / "old_job", (1000000, 1000000))
result = _find_latest_export(tmp_path)
assert [d.name for d in result] == ["new_job"]
def test_newest_per_resource_type(self, tmp_path: Path) -> None:
"""Three single-type v3 jobs: each type comes from its own dir."""
import os
exports = tmp_path / "exports"
exports.mkdir()
for i, (name, rtype) in enumerate(
[
("j_eob", "ExplanationOfBenefit"),
("j_pat", "Patient"),
("j_cov", "Coverage"),
]
):
d = exports / name
d.mkdir()
(d / f"{rtype}.ndjson").write_text("{}\n")
os.utime(d, (1000000 + i, 1000000 + i))
# a newer Patient-only job supersedes j_pat
d = exports / "j_pat2"
d.mkdir()
(d / "Patient.ndjson").write_text("{}\n")
os.utime(d, (2000000, 2000000))
result = _find_latest_export(tmp_path)
assert [d.name for d in result] == ["j_eob", "j_cov", "j_pat2"]
class TestLoadV3EndToEnd:
"""Official v3 samples, split like three single-type jobs → DuckDB."""
def test_load_from_three_export_dirs(self, tmp_path: Path) -> None:
import shutil
import duckdb
from aco.load.bcda import load_bcda
from conf import path as conf_path
samples = Path(__file__).resolve().parents[2] / "dev" / "vendor" / "bcda-site"
store = tmp_path / "store"
for job, rtype in (
("j1", "Patient"),
("j2", "Coverage"),
("j3", "ExplanationOfBenefit"),
):
d = store / "exports" / job
d.mkdir(parents=True)
shutil.copy(samples / f"v3_{rtype}.ndjson", d / f"{rtype}.ndjson")
# a second EOB part in the same job dir
shutil.copy(
samples / "v3_ExplanationOfBenefit.ndjson",
store / "exports" / "j3" / "ExplanationOfBenefit.1.ndjson",
)
db = tmp_path / "aco.duckdb"
from unittest.mock import patch
real_path = conf_path
def fake_path(key: str):
if key == "storage.bcda":
return store
return real_path(key)
with patch("conf.path", side_effect=fake_path):
stats = load_bcda(database=str(db))
assert stats == {
"bcda.patient": 94,
"bcda.explanation_of_benefit": 538,
"bcda.eob_item": 2464,
"bcda.coverage": 175,
}
con = duckdb.connect(str(db), read_only=True)
plans = dict(
con.execute(
"select class_plan, count(*) from bcda.coverage group by 1"
).fetchall()
)
assert set(plans) == {"Part A", "Part B", "Part C", "Dual Medicare/Medicaid"}
sys_types = dict(
con.execute(
"select system_type, count(*) from bcda.explanation_of_benefit group by 1"
).fetchall()
)
assert sys_types == {"NationalClaimsHistory": 282, "DDPS": 256}
assert (
con.execute(
"select count(*) from bcda.patient where bene_mbi_id is null"
).fetchone()[0]
== 0
)
con.close()