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
389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""bcda.client — v3 additions: typeFilter, OperationOutcome errors,
|
|
job history Bundle, completed-job reuse, cancellation, version/health."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gzip
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from bcda.client import (
|
|
ASYNC_HEADERS,
|
|
SANDBOX,
|
|
SYSTEM_TYPES,
|
|
BcdaHttpError,
|
|
Client,
|
|
JobCancelledError,
|
|
_requested_types,
|
|
operation_outcome,
|
|
raise_for_status,
|
|
type_filter_tag,
|
|
)
|
|
|
|
|
|
def _token_response(tok: str = "tok") -> httpx.Response:
|
|
return httpx.Response(200, json={"access_token": tok, "expires_in": 1200})
|
|
|
|
|
|
def _oo(text: str, *, code: int = 400, details: bool = False) -> httpx.Response:
|
|
issue: dict = {"severity": "error", "code": "structure"}
|
|
if details:
|
|
issue["details"] = {"text": text}
|
|
else:
|
|
issue["diagnostics"] = text
|
|
return httpx.Response(
|
|
code, json={"resourceType": "OperationOutcome", "issue": [issue]}
|
|
)
|
|
|
|
|
|
def _client(handler, **kw) -> Client:
|
|
kw.setdefault("output_dir", "/tmp/bcda-test")
|
|
c = Client("cid", "csec", **kw)
|
|
c._http = httpx.Client(transport=httpx.MockTransport(handler))
|
|
return c
|
|
|
|
|
|
class TestHelpers:
|
|
def test_type_filter_tag(self) -> None:
|
|
assert type_filter_tag("DDPS") == (
|
|
"ExplanationOfBenefit?_tag=https://bluebutton.cms.gov/fhir/CodeSystem/System-Type|DDPS"
|
|
)
|
|
assert type_filter_tag(*SYSTEM_TYPES).count("|") == 3
|
|
with pytest.raises(ValueError):
|
|
type_filter_tag("Bogus")
|
|
with pytest.raises(ValueError):
|
|
type_filter_tag()
|
|
|
|
def test_operation_outcome(self) -> None:
|
|
assert (
|
|
operation_outcome(_oo("Prefer header is required"))
|
|
== "Prefer header is required"
|
|
)
|
|
assert operation_outcome(_oo("via details", details=True)) == "via details"
|
|
assert operation_outcome(httpx.Response(500, content=b"<html>")) == ""
|
|
assert (
|
|
operation_outcome(httpx.Response(404, json={"resourceType": "Bundle"}))
|
|
== ""
|
|
)
|
|
assert operation_outcome(httpx.Response(404, json=[1, 2])) == ""
|
|
assert (
|
|
operation_outcome(
|
|
httpx.Response(
|
|
400,
|
|
json={
|
|
"resourceType": "OperationOutcome",
|
|
"issue": [{"severity": "error"}],
|
|
},
|
|
)
|
|
)
|
|
== ""
|
|
)
|
|
|
|
def test_raise_for_status(self) -> None:
|
|
raise_for_status(httpx.Response(200)) # no-op
|
|
resp = _oo("bad", code=404)
|
|
resp.request = httpx.Request("GET", "https://x/y")
|
|
with pytest.raises(BcdaHttpError) as ei:
|
|
raise_for_status(resp)
|
|
assert ei.value.status_code == 404
|
|
assert ei.value.diagnostics == "bad"
|
|
assert "bad" in str(ei.value)
|
|
assert isinstance(ei.value, httpx.HTTPStatusError)
|
|
plain = httpx.Response(500, content=b"")
|
|
plain.request = httpx.Request("GET", "https://x/y")
|
|
with pytest.raises(BcdaHttpError) as ei:
|
|
raise_for_status(plain)
|
|
assert ei.value.diagnostics == "" and "500" in str(ei.value)
|
|
|
|
def test_requested_types(self) -> None:
|
|
req = "GET https://sandbox.bcda.cms.gov/api/v3/Group/all/$export?_type=Patient%2CCoverage%2CExplanationOfBenefit"
|
|
assert _requested_types(req) == ["Patient", "Coverage", "ExplanationOfBenefit"]
|
|
assert _requested_types("GET https://x/api/v3/Group/all/$export") == []
|
|
assert _requested_types("https://x/api/v3/Patient/$export?_type=Patient") == [
|
|
"Patient"
|
|
]
|
|
|
|
|
|
class TestExportTypeFilter:
|
|
def test_params_sent(self) -> None:
|
|
captured: dict = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
captured["url"] = str(req.url)
|
|
captured["prefer"] = req.headers.get("prefer")
|
|
return httpx.Response(
|
|
202, headers={"Content-Location": f"{SANDBOX}/api/v3/jobs/1"}
|
|
)
|
|
|
|
c = _client(handler)
|
|
url = c.start_export(
|
|
types=["ExplanationOfBenefit"],
|
|
since="2026-01-01T00:00:00Z",
|
|
type_filter=type_filter_tag("SharedSystem"),
|
|
)
|
|
assert url.endswith("/api/v3/jobs/1")
|
|
assert "_typeFilter=ExplanationOfBenefit%3F_tag%3D" in captured["url"]
|
|
assert "_since=2026-01-01" in captured["url"]
|
|
assert captured["prefer"] == "respond-async"
|
|
c.close()
|
|
|
|
def test_export_passes_type_filter(self, tmp_path) -> None:
|
|
calls: list[str] = []
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
url = str(req.url)
|
|
calls.append(url)
|
|
if "/auth/token" in url:
|
|
return _token_response()
|
|
if "$export" in url:
|
|
return httpx.Response(
|
|
202, headers={"Content-Location": f"{SANDBOX}/api/v3/jobs/9"}
|
|
)
|
|
if url.endswith("/jobs/9"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"output": [
|
|
{"type": "Patient", "url": f"{SANDBOX}/data/9/a.ndjson"}
|
|
],
|
|
"error": [],
|
|
},
|
|
)
|
|
return httpx.Response(200, content=b'{"resourceType":"Patient"}\n')
|
|
|
|
c = _client(handler, output_dir=tmp_path)
|
|
paths = c.export(
|
|
types=["Patient"],
|
|
type_filter="ExplanationOfBenefit?outcome=partial",
|
|
poll_interval=0,
|
|
)
|
|
assert [p.name for p in paths] == ["Patient.ndjson"]
|
|
assert any("_typeFilter=" in u for u in calls)
|
|
c.close()
|
|
|
|
|
|
class TestErrors:
|
|
def test_client_error_carries_diagnostics(self) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return _oo("application/fhir+json is the only supported response format")
|
|
|
|
c = _client(handler)
|
|
with pytest.raises(BcdaHttpError, match="only supported response format"):
|
|
c._request("GET", f"{SANDBOX}/api/v3/jobs")
|
|
c.close()
|
|
|
|
def test_auth_failure_diagnostics(self) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
return _oo("invalid client", code=401)
|
|
|
|
c = _client(handler)
|
|
with pytest.raises(BcdaHttpError, match="invalid client"):
|
|
c.authenticate()
|
|
c.close()
|
|
|
|
@patch("time.sleep")
|
|
def test_5xx_exhausted_is_bcda_error(self, _sleep) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return httpx.Response(503, content=b"down")
|
|
|
|
c = _client(handler, max_retries=2)
|
|
with pytest.raises(BcdaHttpError) as ei:
|
|
c._request("GET", f"{SANDBOX}/x")
|
|
assert ei.value.status_code == 503
|
|
c.close()
|
|
|
|
def test_poll_cancelled(self) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return _oo("Job has been cancelled.", code=404)
|
|
|
|
c = _client(handler)
|
|
with pytest.raises(JobCancelledError, match="cancelled"):
|
|
c.poll(f"{SANDBOX}/api/v3/jobs/5", interval=0)
|
|
c.close()
|
|
|
|
def test_poll_other_404_propagates(self) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return _oo("Job not found", code=404)
|
|
|
|
c = _client(handler)
|
|
with pytest.raises(BcdaHttpError, match="not found"):
|
|
c.poll(f"{SANDBOX}/api/v3/jobs/5", interval=0)
|
|
c.close()
|
|
|
|
def test_poll_sends_bulk_headers(self) -> None:
|
|
seen: dict = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
seen.update({k.lower(): v for k, v in req.headers.items()})
|
|
return httpx.Response(200, json={"output": []})
|
|
|
|
c = _client(handler)
|
|
c.poll(f"{SANDBOX}/api/v3/jobs/5", interval=0)
|
|
assert (
|
|
seen["prefer"] == "respond-async"
|
|
and seen["accept"] == ASYNC_HEADERS["Accept"]
|
|
)
|
|
c.close()
|
|
|
|
def test_download_error_has_diagnostics(self, tmp_path) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return _oo("file expired", code=410)
|
|
|
|
c = _client(handler, output_dir=tmp_path)
|
|
with pytest.raises(BcdaHttpError, match="file expired"):
|
|
c._download_file(f"{SANDBOX}/data/1/x.ndjson", tmp_path / "x.ndjson")
|
|
c.close()
|
|
|
|
def test_download_gzip_fixture(self, tmp_path) -> None:
|
|
"""The upstream gzip fixture decodes to the same bytes as the plain one."""
|
|
from pathlib import Path
|
|
|
|
fixtures = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "dev"
|
|
/ "vendor"
|
|
/ "bcda-app"
|
|
/ "shared_files"
|
|
/ "gzip_feature_test"
|
|
)
|
|
gz = (fixtures / "test_gzip_encoded.ndjson").read_bytes()
|
|
plain = (fixtures / "test_no_encoding.ndjson").read_bytes()
|
|
assert gzip.decompress(gz).strip() == plain.strip()
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return httpx.Response(200, content=gz, headers={"Content-Encoding": "gzip"})
|
|
|
|
c = _client(handler, output_dir=tmp_path)
|
|
c._download_file(f"{SANDBOX}/data/1/x.ndjson", tmp_path / "x.ndjson")
|
|
assert (tmp_path / "x.ndjson").read_bytes().strip() == plain.strip()
|
|
c.close()
|
|
|
|
|
|
def _task(job_id: str, status: str, request: str, end: str) -> dict:
|
|
return {
|
|
"resourceType": "Task",
|
|
"identifier": [{"system": f"{SANDBOX}/api/v3/jobs", "value": job_id}],
|
|
"status": status,
|
|
"input": [{"type": {"text": "BULK FHIR Export"}, "valueString": request}],
|
|
"executionPeriod": {"start": "2026-10-25T00:00:00Z", "end": end},
|
|
}
|
|
|
|
|
|
class TestJobs:
|
|
def test_bundle_unwrapped_with_headers(self) -> None:
|
|
seen: dict = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
seen["prefer"] = req.headers.get("prefer")
|
|
seen["accept"] = req.headers.get("accept")
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"resourceType": "Bundle",
|
|
"type": "searchset",
|
|
"entry": [
|
|
{"resource": _task("1", "completed", "GET x", "2026")},
|
|
{"fullUrl": "no-resource"},
|
|
],
|
|
},
|
|
)
|
|
|
|
c = _client(handler)
|
|
jobs = c.jobs()
|
|
assert len(jobs) == 1 and jobs[0]["status"] == "completed"
|
|
assert seen == {"prefer": "respond-async", "accept": "application/fhir+json"}
|
|
c.close()
|
|
|
|
def test_status_404_means_empty(self) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return _oo("no jobs", code=404)
|
|
|
|
c = _client(handler)
|
|
assert c.jobs(status="Completed") == []
|
|
with pytest.raises(BcdaHttpError):
|
|
c.jobs() # unfiltered 404 is a real error
|
|
c.close()
|
|
|
|
def test_completed_jobs(self) -> None:
|
|
tasks = [
|
|
_task(
|
|
"10",
|
|
"completed",
|
|
f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Patient",
|
|
"2026-10-25T10:00:00Z",
|
|
),
|
|
_task(
|
|
"11",
|
|
"completed",
|
|
f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Coverage%2CPatient",
|
|
"2026-10-25T12:00:00Z",
|
|
),
|
|
_task(
|
|
"12",
|
|
"cancelled",
|
|
f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Patient",
|
|
"2026-10-25T13:00:00Z",
|
|
),
|
|
_task(
|
|
"13",
|
|
"completed",
|
|
f"GET {SANDBOX}/api/v2/Group/all/$export?_type=Patient",
|
|
"2026-10-25T14:00:00Z",
|
|
),
|
|
{"resourceType": "Task", "status": "completed"},
|
|
]
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return _token_response()
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"resourceType": "Bundle",
|
|
"entry": [{"resource": t} for t in tasks],
|
|
},
|
|
)
|
|
|
|
c = _client(handler)
|
|
got = c.completed_jobs()
|
|
assert [j["job_id"] for j in got] == ["11", "10"] # newest first, v3 only
|
|
assert got[0]["types"] == ["Coverage", "Patient"]
|
|
assert got[0]["job_url"].endswith("/api/v3/jobs/11")
|
|
c.close()
|
|
|
|
|
|
class TestInfoEndpoints:
|
|
def test_version_and_health_unauthenticated(self) -> None:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
assert "Authorization" not in req.headers
|
|
if str(req.url).endswith("/_version"):
|
|
return httpx.Response(200, json={"version": "r302"})
|
|
return httpx.Response(200, json={"database": "ok"})
|
|
|
|
c = _client(handler)
|
|
assert c.server_version()["version"] == "r302"
|
|
assert c.health()["database"] == "ok"
|
|
c.close()
|