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
125 lines
4.1 KiB
Python
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()
|