Files
stack/tests/aco/test_load_modules.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

126 lines
4.0 KiB
Python

"""Tests for aco.load.bcda, aco.load.stage modules."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
class TestLoadBcda:
def test_load_bcda_with_dir(self, tmp_path) -> None:
flat_dir = tmp_path / "flat"
flat_dir.mkdir()
(flat_dir / "patient.parquet").write_text("fake")
with (
patch("aco.load.bcda.Path"),
patch("duckdb.connect") as mock_con,
patch("conf.path", return_value=tmp_path),
):
con = MagicMock()
con.execute.return_value.fetchone.return_value = (42,)
mock_con.return_value = con
from aco.load.bcda import load_bcda
load_bcda(ndjson_dir=tmp_path, database=str(tmp_path / "db"))
def test_find_latest_export_missing(self, tmp_path) -> None:
from aco.load.bcda import _find_latest_export
with pytest.raises(FileNotFoundError, match="No exports directory"):
_find_latest_export(tmp_path)
def test_find_latest_export_empty(self, tmp_path) -> None:
(tmp_path / "exports").mkdir()
from aco.load.bcda import _find_latest_export
with pytest.raises(FileNotFoundError, match="No export directories"):
_find_latest_export(tmp_path)
def test_find_latest_export_ok(self, tmp_path) -> None:
exports = tmp_path / "exports"
exports.mkdir()
(exports / "job_001").mkdir()
(exports / "job_002").mkdir()
from aco.load.bcda import _find_latest_export
result = _find_latest_export(tmp_path)
assert len(result) == 1 and result[0].is_dir()
class TestStageAll:
def test_stage_empty(self) -> None:
from aco.load.stage import stage_all
result = stage_all()
assert result == {}
def test_stage_cclf(self) -> None:
with patch(
"aco.load.cclf.load_cclf_directory",
return_value={"cclf.cclf1": 100},
):
from aco.load.stage import stage_all
result = stage_all(cclf_dir=Path("/tmp/fake"))
assert "cclf" in result
assert result["cclf"]["cclf.cclf1"] == 100
def test_stage_bcda(self) -> None:
with patch(
"aco.load.bcda.load_bcda",
return_value={"bcda.patient": 50},
):
from aco.load.stage import stage_all
result = stage_all(bcda_ndjson_dir=Path("/tmp/fake"))
assert "bcda" in result
def test_stage_seed(self) -> None:
with patch(
"aco.load.seed.load_seeds",
return_value={"ref.zips": 42000},
):
from aco.load.stage import stage_all
result = stage_all(seed_dir=Path("/tmp/fake"))
assert "seed" in result
def test_stage_promote_to_iceberg(self) -> None:
with (
patch(
"aco.load.cclf.load_cclf_directory",
return_value={"cclf.cclf1": 100},
),
patch("aco.load.stage._promote_to_iceberg") as mock_promote,
):
from aco.load.stage import stage_all
stage_all(cclf_dir=Path("/tmp/fake"), promote_to_iceberg=True)
mock_promote.assert_called_once()
def test_promote_to_iceberg(self) -> None:
mock_src = MagicMock()
mock_ice = MagicMock()
mock_df = MagicMock()
mock_src.load.return_value = mock_df
with (
patch("aco.lake.context.DuckDBContext", return_value=mock_src),
patch("aco.lake.context.IcebergContext", return_value=mock_ice),
patch("conf.cfg") as mock_cfg,
patch("conf.path", return_value="/tmp/fake.duckdb"),
):
mock_cfg.lake.nessie.catalog_uri = "http://nessie:19120"
mock_cfg.lake.warehouse = "s3://wh"
mock_cfg.lake.get.return_value = "http://rustfs:9000"
from aco.load.stage import _promote_to_iceberg
_promote_to_iceberg({"cclf": {"cclf.cclf1": 100}})
mock_ice.save.assert_called_once()