Some checks failed
CI / lint (push) Successful in 54s
CI / notebooks-smoke (push) Successful in 1m43s
CI / test (push) Failing after 2m56s
Infra CI / zotero (push) Successful in 15s
Infra CI / notebooks (push) Successful in 1m1s
Infra CI / api (push) Successful in 1m7s
Infra CI / docs (push) Successful in 1m50s
Infra CI / llm (push) Successful in 49s
Infra CI / mc (push) Failing after 35s
Infra CI / bcda (push) Successful in 1m42s
CI / lint (pull_request) Successful in 45s
CI / notebooks-smoke (pull_request) Successful in 1m38s
CI / test (pull_request) Failing after 2m53s
Infra CI / mc (pull_request) Failing after 30s
Infra CI / zotero (pull_request) Successful in 16s
Infra CI / notebooks (pull_request) Successful in 58s
Infra CI / docs (pull_request) Successful in 16s
Infra CI / api (pull_request) Successful in 15s
Infra CI / bcda (pull_request) Successful in 21s
Infra CI / llm (pull_request) Successful in 17s
Closes #1293 #1294 #1295 #1296 #1297 #1298 #1299 #1300 (upstream review docs/superpowers/specs/2026-10-25-bcda-upstream-review.md). Partial exports (#1293). The manifest's `error` list is downloaded as OperationOutcome[.N].ndjson with resource_type=OperationOutcome; every URL is attempted and failures are recorded per file, then raised once as DownloadError. A job with error files is `partial`: it still reconciles (its rows are real) but never becomes the watermark, check_since warns `partial_job`, the Store page lists the issues and `stack bcda errors` prints them. Claim identity (#1294), per bcda.cms.gov "Matching multiple EOBs to the same claim": three new header columns — clm_cntl_num_match (VMS leading 1 stripped on SharedSystem DME), clm_natural_key (Part D events without a control number: bene · line from-date · provider qualifier · service provider · RX number · fill number) and superseded_by — plus prvdr_srvc_id_qlfyr_cd. bcda.sync keys claims on match → control number → natural key → id, marks every claim another current claim names in `related` as superseded (chain walked), counts `superseded`, and a new invariant forbids a live claim being named predecessor by another live claim. The connector dedupes on the same identity and drops superseded rows. Partitions and current tables flattened before these columns existed get them as NULLs. Attribution (#1295): `attribution_expired` (block) from expiration_dates, `attribution_floor` (warn) from the earliest last_attribution_update recorded on job rows (new column), and the server's 404/400/429 texts explained by explain_server_error. Adoption (#1296): completed_jobs() drops jobs older than the 24 h file retention; downloads no longer stop at the first failed file. _typeFilter (#1297): build_type_filter follows bcda/fhir/param.go — outcome lists, service-date earliest and latest bounds with eq/gt/ge/lt/le on YYYY[-MM[-DD]], no comma lists, no conflicting prefixes; CLI and Exports page expose them. Runout (#1298): `runout_window` warn after July; population column on the Sync page ledger. Vendoring (#1299): the manifest now pins bcda-app's param.go, bluebutton.go, service.go, constants, models and prod.yml, the docs source (CMSgov/bcda-static-site: filter/access/migrate/PAC/FAQ pages and announcements) and the PAC and unmapped-fields dictionaries. Docs (#1300): the reconciliation design record states the server's window `(since, transactionTime]`, the lookback bound, the silent exclusions, re-loads, partial exports and the identity rules. refs #1139
528 lines
17 KiB
Python
528 lines
17 KiB
Python
"""stack bcda — the manager's CLI face, with the service layer stubbed."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from bcda.ui import service as svc
|
|
from cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class _FakeClient:
|
|
base_url = svc.SANDBOX
|
|
version = "v3"
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
def __enter__(self): # noqa: ANN204
|
|
return self
|
|
|
|
def __exit__(self, *exc): # noqa: ANN002, ANN204
|
|
return False
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_env(monkeypatch):
|
|
monkeypatch.delenv("BCDA_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("BCDA_CLIENT_SECRET", raising=False)
|
|
monkeypatch.setattr(svc, "make_client", lambda conn, output_dir=None: _FakeClient())
|
|
|
|
|
|
def test_help_lists_commands() -> None:
|
|
result = runner.invoke(app, ["bcda", "--help"])
|
|
assert result.exit_code == 0
|
|
for cmd in (
|
|
"ui",
|
|
"preflight",
|
|
"auth",
|
|
"jobs",
|
|
"export",
|
|
"adopt",
|
|
"cancel",
|
|
"store",
|
|
"load",
|
|
"vendor",
|
|
):
|
|
assert cmd in result.output
|
|
|
|
|
|
def test_unknown_preset() -> None:
|
|
result = runner.invoke(app, ["bcda", "preflight", "--preset", "Nope"])
|
|
assert result.exit_code == 1 and "Unknown preset" in result.output
|
|
|
|
|
|
def test_env_credentials_win(monkeypatch) -> None:
|
|
monkeypatch.setenv("BCDA_CLIENT_ID", "env")
|
|
monkeypatch.setenv("BCDA_CLIENT_SECRET", "secret")
|
|
seen = {}
|
|
|
|
def remember(conn, output_dir=None): # noqa: ANN001, ANN202
|
|
seen["conn"] = conn
|
|
return _FakeClient()
|
|
|
|
monkeypatch.setattr(svc, "make_client", remember)
|
|
monkeypatch.setattr(svc, "preflight", lambda c: {"ok": True})
|
|
assert runner.invoke(app, ["bcda", "preflight", "--preset", "Nope"]).exit_code == 0
|
|
assert seen["conn"].client_id == "env"
|
|
|
|
|
|
def test_preflight_and_auth(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
svc, "preflight", lambda c: {"ok": True, "fhir_version": "4.0.1"}
|
|
)
|
|
result = runner.invoke(app, ["bcda", "preflight"])
|
|
assert result.exit_code == 0 and '"fhir_version": "4.0.1"' in result.output
|
|
monkeypatch.setattr(svc, "preflight", lambda c: {"ok": False, "error": "down"})
|
|
assert runner.invoke(app, ["bcda", "preflight"]).exit_code == 1
|
|
monkeypatch.setattr(svc, "auth_check", lambda c: {"token_chars": 40})
|
|
result = runner.invoke(app, ["bcda", "auth", "--preset", "Small Model Entity"])
|
|
assert result.exit_code == 0 and '"token_chars": 40' in result.output
|
|
|
|
|
|
def test_jobs(monkeypatch) -> None:
|
|
rows = [
|
|
{
|
|
"job_id": "1",
|
|
"status": "completed",
|
|
"endpoint": "Group/all",
|
|
"types": "Patient",
|
|
"started": "s",
|
|
"ended": "e",
|
|
"reusable": True,
|
|
"job_url": "u",
|
|
}
|
|
]
|
|
monkeypatch.setattr(svc, "server_jobs", lambda c, limit: rows[:limit])
|
|
result = runner.invoke(app, ["bcda", "jobs", "--limit", "1"])
|
|
assert (
|
|
result.exit_code == 0
|
|
and "reusable" in result.output
|
|
and "1 job(s)" in result.output
|
|
)
|
|
|
|
|
|
class _FakeStore:
|
|
def get_files(self, key): # noqa: ANN001, ANN202
|
|
return [{"storage_path": f"{key}/a"}]
|
|
|
|
|
|
def test_export_per_type_and_one_job(monkeypatch) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore())
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"export_per_type",
|
|
lambda store, client, **kw: calls.append(("per", kw)) or {"Patient": "K1"},
|
|
)
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"start_export",
|
|
lambda store, client, **kw: calls.append(("one", kw)) or "K2",
|
|
)
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"bcda",
|
|
"export",
|
|
"-t",
|
|
"Patient",
|
|
"--system-type",
|
|
"DDPS",
|
|
"--outcome",
|
|
"complete",
|
|
"--since",
|
|
"2026-01-01T00:00:00+00:00",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert (
|
|
"_typeFilter=ExplanationOfBenefit?_tag=" in result.output
|
|
and "Patient: job K1, 1 file(s)" in result.output
|
|
)
|
|
assert (
|
|
calls[0][0] == "per"
|
|
and calls[0][1]["types"] == ["Patient"]
|
|
and calls[0][1]["since"].startswith("2026")
|
|
)
|
|
result = runner.invoke(
|
|
app, ["bcda", "export", "--one-job", "--endpoint", "Group/runout"]
|
|
)
|
|
assert (
|
|
result.exit_code == 0
|
|
and "Patient,Coverage,ExplanationOfBenefit: job K2" in result.output
|
|
)
|
|
assert (
|
|
calls[-1][0] == "one"
|
|
and calls[-1][1]["endpoint"] == "Group/runout"
|
|
and calls[-1][1]["type_filter"] is None
|
|
)
|
|
|
|
|
|
def test_adopt_and_cancel(monkeypatch) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore())
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"adopt_job",
|
|
lambda store, client, url, types=None: (
|
|
seen.update(url=url, types=types) or "KA"
|
|
),
|
|
)
|
|
result = runner.invoke(app, ["bcda", "adopt", "77", "-t", "Patient"])
|
|
assert result.exit_code == 0 and "adopted job 77 as KA" in result.output
|
|
assert seen["url"].endswith("/api/v3/jobs/77") and seen["types"] == ["Patient"]
|
|
monkeypatch.setattr(svc, "cancel_job", lambda client, url: seen.update(cancel=url))
|
|
result = runner.invoke(app, ["bcda", "cancel", "78"])
|
|
assert result.exit_code == 0 and seen["cancel"].endswith("/jobs/78")
|
|
|
|
class _ResumeStore(_FakeStore):
|
|
def get_job(self, key): # noqa: ANN001, ANN202
|
|
return {"status": "complete", "transaction_time": "2026-10-25T00:00:00Z"}
|
|
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _ResumeStore())
|
|
monkeypatch.setattr(
|
|
svc, "resume_job", lambda store, client, key: seen.update(resume=key) or key
|
|
)
|
|
result = runner.invoke(app, ["bcda", "resume", "K9"])
|
|
assert result.exit_code == 0, result.output
|
|
assert (
|
|
seen["resume"] == "K9"
|
|
and "transactionTime=2026-10-25T00:00:00Z" in result.output
|
|
)
|
|
|
|
|
|
def test_store_load_vendor(monkeypatch, tmp_path) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: object())
|
|
monkeypatch.setattr(svc, "store_path", lambda: tmp_path)
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"store_jobs",
|
|
lambda s: [
|
|
{
|
|
"key": "K1",
|
|
"status": "complete",
|
|
"types": "Patient",
|
|
"files": 2,
|
|
"bytes": 1234,
|
|
"ts": "t",
|
|
}
|
|
],
|
|
)
|
|
result = runner.invoke(app, ["bcda", "store"])
|
|
assert (
|
|
result.exit_code == 0 and "K1" in result.output and "1 job(s)" in result.output
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "load_and_publish", lambda **kw: {"bcda.patient": 5, **{"kw": len(kw)}}
|
|
)
|
|
result = runner.invoke(app, ["bcda", "load", "--no-pipeline", "--skip-flatten"])
|
|
assert (
|
|
result.exit_code == 0
|
|
and "bcda.patient: 5 rows" in result.output
|
|
and "Replica published" in result.output
|
|
)
|
|
|
|
monkeypatch.setattr(svc, "vendor_status", lambda: {"available": False})
|
|
assert runner.invoke(app, ["bcda", "vendor"]).exit_code == 1
|
|
status = {
|
|
"available": True,
|
|
"sources": [
|
|
{
|
|
"name": "bcda-app",
|
|
"kind": "github",
|
|
"origin": "CMSgov/bcda-app",
|
|
"commit": "abcdef123456789",
|
|
"files": 3,
|
|
}
|
|
],
|
|
"files": [],
|
|
"problems": [],
|
|
}
|
|
monkeypatch.setattr(svc, "vendor_status", lambda: status)
|
|
monkeypatch.setattr(svc, "vendor_check", lambda: [])
|
|
result = runner.invoke(app, ["bcda", "vendor", "--check"])
|
|
assert (
|
|
result.exit_code == 0
|
|
and "matches the manifest" in result.output
|
|
and "no upstream drift" in result.output
|
|
)
|
|
monkeypatch.setattr(svc, "vendor_check", lambda: ["bcda-app: swagger.json changed"])
|
|
result = runner.invoke(app, ["bcda", "vendor", "--check"])
|
|
assert result.exit_code == 1 and "1 upstream change(s)" in result.output
|
|
status["problems"] = ["missing x"]
|
|
result = runner.invoke(app, ["bcda", "vendor"])
|
|
assert result.exit_code == 1 and "! missing x" in result.output
|
|
|
|
|
|
def test_ui_launches_streamlit(monkeypatch) -> None:
|
|
with patch("subprocess.call", return_value=0) as call:
|
|
result = runner.invoke(app, ["bcda", "ui", "--port", "9999", "--no-headless"])
|
|
assert result.exit_code == 0 and "http://localhost:9999" in result.output
|
|
cmd = call.call_args.args[0]
|
|
assert cmd[1:3] == ["-m", "bcda.ui"]
|
|
assert cmd[cmd.index("--server.headless") + 1] == "false"
|
|
|
|
|
|
# ── sync ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_help_lists_sync() -> None:
|
|
result = runner.invoke(app, ["bcda", "sync", "--help"])
|
|
assert result.exit_code == 0
|
|
for cmd in ("status", "check", "apply", "pull"):
|
|
assert cmd in result.output
|
|
|
|
|
|
def test_export_since_auto_refused(monkeypatch) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore())
|
|
monkeypatch.setattr(
|
|
svc, "resolve_since", lambda store, since, **kw: {"Patient": None}
|
|
)
|
|
|
|
def refuse(store, client, **kw): # noqa: ANN001, ANN003, ANN202
|
|
raise svc.SinceBlocked(
|
|
[
|
|
{
|
|
"level": "block",
|
|
"code": "no_baseline",
|
|
"message": "Patient: none",
|
|
"resource_type": "Patient",
|
|
}
|
|
]
|
|
)
|
|
|
|
monkeypatch.setattr(svc, "export_per_type", refuse)
|
|
result = runner.invoke(app, ["bcda", "export", "-t", "Patient", "--since", "auto"])
|
|
assert result.exit_code == 2
|
|
assert "_since Patient: — no baseline —" in result.output
|
|
assert "✗ no_baseline" in result.output and "--force" in result.output
|
|
|
|
|
|
def test_sync_status_and_check(monkeypatch) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore())
|
|
status = {
|
|
"watermarks": [
|
|
{
|
|
"population": "current",
|
|
"type_filter": "",
|
|
"resource_type": "Patient",
|
|
"job_key": "B1",
|
|
"transaction_time": "2026-10-01T00:00:00.000+00:00",
|
|
"kind": "baseline",
|
|
"applied": False,
|
|
}
|
|
],
|
|
"jobs": [
|
|
{
|
|
"key": "B1",
|
|
"kind": "baseline",
|
|
"endpoint": "Group/all",
|
|
"type_filter": "",
|
|
"types": "Patient",
|
|
"since": "",
|
|
"transaction_time": "2026-10-01T00:00:00.000+00:00",
|
|
"transaction_source": "job_row",
|
|
"applied": False,
|
|
}
|
|
],
|
|
"pending": ["B1"],
|
|
"ledger": [
|
|
{
|
|
"applied_seq": 1,
|
|
"job_key": "B0",
|
|
"kind": "baseline",
|
|
"ok": True,
|
|
"counts": {"patient": {"new": 3, "replaced": 0, "retired": 0}},
|
|
"notes": [],
|
|
}
|
|
],
|
|
"current": {"patient": 3},
|
|
}
|
|
monkeypatch.setattr(svc, "sync_status", lambda store=None: status)
|
|
result = runner.invoke(app, ["bcda", "sync", "status"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "(not yet applied)" in result.output and "PENDING" in result.output
|
|
assert "patient=3" in result.output and "patient:3+/0~/0-" in result.output
|
|
result = runner.invoke(app, ["bcda", "sync", "status", "--json"])
|
|
assert result.exit_code == 0 and '"pending"' in result.output
|
|
|
|
plan = {"Patient": "2026-10-01T00:00:00.000+00:00", "Coverage": None}
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"resolve_since",
|
|
lambda store, since, **kw: {
|
|
t: v for t, v in plan.items() if not kw.get("types") or t in kw["types"]
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"since_checks",
|
|
lambda store, since, **kw: [
|
|
{
|
|
"level": "ok",
|
|
"code": "watermark",
|
|
"message": "fine",
|
|
"resource_type": "Patient",
|
|
}
|
|
],
|
|
)
|
|
result = runner.invoke(app, ["bcda", "sync", "check", "--offline"])
|
|
assert result.exit_code == 2 # Coverage has no baseline
|
|
assert (
|
|
"✓ watermark" in result.output
|
|
and "Coverage: _since — no baseline —" in result.output
|
|
)
|
|
result = runner.invoke(app, ["bcda", "sync", "check", "auto", "-t", "Patient"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
def test_sync_apply_and_pull(monkeypatch) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore())
|
|
counts = {k: 0 for k in svc.sync.COUNT_FIELDS} | {
|
|
"rows": 10,
|
|
"new": 3,
|
|
"current": 13,
|
|
}
|
|
report = {
|
|
"ok": True,
|
|
"applied": [
|
|
{
|
|
"job_key": "D1",
|
|
"kind": "delta",
|
|
"since": "2026-10-01T00:00:00.000+00:00",
|
|
"transaction_time": "2026-10-08T00:00:00.000+00:00",
|
|
"ok": True,
|
|
"counts": {"explanation_of_benefit": counts},
|
|
"notes": ["transactionTime from job_row"],
|
|
}
|
|
],
|
|
"skipped": ["B1"],
|
|
}
|
|
seen: dict = {}
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"apply_sync",
|
|
lambda store=None, keys=None, force=False: (
|
|
seen.update(keys=keys, force=force) or report
|
|
),
|
|
)
|
|
monkeypatch.setattr(svc, "load_and_publish", lambda **kw: {"bcda.patient": 5})
|
|
result = runner.invoke(app, ["bcda", "sync", "apply", "--force", "-k", "D1"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "D1 delta" in result.output and "new= 3" in result.output
|
|
assert (
|
|
"already applied: B1" in result.output and "Replica published" in result.output
|
|
)
|
|
assert seen == {"keys": ["D1"], "force": True}
|
|
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"resolve_since",
|
|
lambda store, since, **kw: {"Patient": "2026-10-01T00:00:00.000+00:00"},
|
|
)
|
|
calls: list = []
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"export_per_type",
|
|
lambda store, client, **kw: calls.append(kw) or {"Patient": "D2"},
|
|
)
|
|
result = runner.invoke(app, ["bcda", "sync", "pull", "-t", "Patient", "--no-apply"])
|
|
assert result.exit_code == 0, result.output
|
|
assert calls[0]["since"] == "auto" and "Patient: job D2" in result.output
|
|
result = runner.invoke(app, ["bcda", "sync", "pull", "-t", "Patient"])
|
|
assert result.exit_code == 0 and "Replica published" in result.output
|
|
|
|
def refuse(store, client, **kw): # noqa: ANN001, ANN003, ANN202
|
|
raise svc.SinceBlocked(
|
|
[
|
|
{
|
|
"level": "block",
|
|
"code": "gap",
|
|
"message": "gap",
|
|
"resource_type": "Patient",
|
|
}
|
|
]
|
|
)
|
|
|
|
monkeypatch.setattr(svc, "export_per_type", refuse)
|
|
result = runner.invoke(app, ["bcda", "sync", "pull", "-t", "Patient"])
|
|
assert result.exit_code == 2 and "✗ gap" in result.output
|
|
|
|
|
|
def test_errors_command_and_filter_options(monkeypatch) -> None:
|
|
monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore())
|
|
monkeypatch.setattr(svc, "job_error_diagnostics", lambda store, key, limit=50: [])
|
|
result = runner.invoke(app, ["bcda", "errors", "K1"])
|
|
assert result.exit_code == 0 and "no error files" in result.output
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"job_error_diagnostics",
|
|
lambda store, key, limit=50: [
|
|
{
|
|
"severity": "error",
|
|
"code": "exception",
|
|
"diagnostics": "BFD 500 for bene",
|
|
"file": "OperationOutcome.ndjson",
|
|
}
|
|
],
|
|
)
|
|
result = runner.invoke(app, ["bcda", "errors", "K1"])
|
|
assert (
|
|
result.exit_code == 0
|
|
and "BFD 500 for bene" in result.output
|
|
and "re-pull" in result.output
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"store_jobs",
|
|
lambda s: [
|
|
{
|
|
"key": "K1",
|
|
"status": "complete",
|
|
"types": "Patient",
|
|
"files": 3,
|
|
"bytes": 1,
|
|
"ts": "t",
|
|
"error_files": 2,
|
|
}
|
|
],
|
|
)
|
|
monkeypatch.setattr(svc, "store_path", lambda: "/tmp")
|
|
result = runner.invoke(app, ["bcda", "store"])
|
|
assert "PARTIAL (2 error file(s))" in result.output
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"export_per_type",
|
|
lambda store, client, **kw: calls.append(kw) or {"ExplanationOfBenefit": "K"},
|
|
)
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"bcda",
|
|
"export",
|
|
"-t",
|
|
"ExplanationOfBenefit",
|
|
"--outcome",
|
|
"partial",
|
|
"--outcome",
|
|
"complete",
|
|
"--service-date-from",
|
|
"ge2026-01",
|
|
"--service-date-to",
|
|
"2026-06-30",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert calls[0]["type_filter"] == (
|
|
"ExplanationOfBenefit?outcome=partial,complete&service-date=ge2026-01&service-date=le2026-06-30"
|
|
)
|