Files
stack/tests/cli/test_cli_bcda.py
kert eb1b574c04
Some checks failed
CI / lint (push) Successful in 48s
CI / notebooks-smoke (push) Successful in 1m46s
Infra CI / zotero (push) Successful in 15s
Infra CI / llm (push) Successful in 1m4s
Infra CI / notebooks (pull_request) Successful in 55s
Infra CI / docs (pull_request) Successful in 17s
Infra CI / api (pull_request) Successful in 14s
Infra CI / llm (pull_request) Successful in 20s
CI / test (push) Failing after 2m26s
Infra CI / notebooks (push) Successful in 52s
Infra CI / api (push) Successful in 1m14s
Infra CI / docs (push) Successful in 1m45s
Infra CI / mc (push) Failing after 42s
CI / lint (pull_request) Successful in 27s
CI / notebooks-smoke (pull_request) Successful in 1m41s
CI / test (pull_request) Failing after 2m20s
Infra CI / zotero (pull_request) Successful in 21s
Infra CI / mc (pull_request) Failing after 32s
Package Supply Chain / pkg-supply-chain (push) Successful in 2m3s
feat(bcda): the BCDA manager — a Streamlit app and stack bcda CLI over the client, store, loader, connector and crosswalk
bcda.ui mirrors the surface of the CMS server (CMSgov/bcda-app, vendored
under dev/vendor): /_version, /_health, metadata, /auth/token,
attribution_status, $export on Group/all · Group/runout · Patient with
_type / _since / _typeFilter, job history, status, cancel and /data
download — plus the local side: the Store's job and file history,
flatten + load into bcda.*, the connector into input_layer.*, the
replica for browsing, the v3 data dictionary and CCLF crosswalk, and the
vendored-upstream manifest with an on-demand drift check.

bcda.ui.service has no Streamlit in it: every operation takes a
Connection / Client / Store, so `stack bcda preflight|auth|jobs|export|
adopt|cancel|store|load|vendor` reuse it verbatim and tests hand in
httpx.MockTransport clients and temp stores. Connections are the four
public sandbox entities, BCDA_CLIENT_ID/SECRET from the environment, or
custom (sandbox or production, v3 or v2). bcda.ui.runner runs exports,
adoption, loads and drift checks on daemon threads in a process-level
registry that survives Streamlit reruns, captures the bcda.* log lines
of each run's own thread, and flips state last so readers never see a
finished run with an incomplete log; a st.fragment panel shows runs
live. Exports default to one job per type with cancel retries (the only
shape that survives the shared sandbox's sweeps); the _typeFilter
builder composes System-Type, outcome and service-date; completed jobs
under 24 h are offered for adopt, in-progress ones for cancel. Data
pages read the read-only replica only.

`stack bcda ui` launches streamlit on src/bcda/ui/app.py; streamlit is
added to the bcda extra. CLI reference pages regenerated (the generator
renumbers sidebar positions, hence the touched pages).

Tests: service, runner, every page through AppTest (buttons, forms,
error paths), the app shell, and the CLI — new modules at 100% line
coverage.

refs #1139
2026-10-25 14:08:04 -04:00

252 lines
8.0 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 __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")
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:4] == ["-m", "streamlit", "run"] and cmd[4].endswith("bcda/ui/app.py")
assert cmd[cmd.index("--server.headless") + 1] == "false"