Files
stack/tests/bcda/test_client_v3.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

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()