From 7218ec76a8ca134d63f76cc9a0feeef06af25c93 Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 18:11:04 -0600 Subject: [PATCH] feat(sp40): cyclone validate-837 CLI + 4 CliRunner tests --- backend/src/cyclone/cli.py | 65 +++++++++++++ backend/tests/test_validate_837_cli.py | 125 +++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 backend/tests/test_validate_837_cli.py diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index fa400cb..60c847f 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -297,6 +297,71 @@ def validate_tax_id_cmd(tax_id: str, log_level: str) -> None: sys.exit(1) +# --------------------------------------------------------------------------- +# SP40: `cyclone validate-837 ` +# +# Sends the file to EdiFabric's /v2/x12/validate endpoint via the +# cyclone.edifabric HTTP client. Prints the OperationResult and exits: +# 0 — Status success (or warning-only) +# 2 — Status error (one or more Details items have Status="error") +# 1 — unexpected exception (network / 5xx / missing API key) +# +# Exit code contract mirrors the rest of the validator family +# (validate-npi, validate-tax-id): 0 valid, 1/2 invalid, file-level +# failures use 2. +# --------------------------------------------------------------------------- + + +@main.command("validate-837") +@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--api-key", default=None, help="Override the Edifabric API key (else read from CYCLONE_EDIFABRIC_API_KEY or keychain).") +@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"])) +def validate_837_cmd(input_file: Path, api_key: str | None, log_level: str) -> None: + """Validate an 837P file via EdiFabric /v2/x12/validate (SP40). + + Sends the file to the Edifabric reference parser and prints the + OperationResult. Exits 0 on success or warning-only, 2 on any + error in Details. Use --api-key to override the key (else the + CLI reads from secrets per the SP40 spec). + """ + setup_logging(level=log_level) + from cyclone import edifabric + + edi_bytes = input_file.read_bytes() + try: + result = edifabric.validate_edi(edi_bytes, api_key=api_key) + except edifabric.EdifabricError as exc: + click.echo(f"VALIDATION ERROR: {exc}", err=True) + if exc.status_code == 0: + # Missing API key / config — exit 1 (unexpected, fixable). + sys.exit(1) + # HTTP failure from Edifabric — exit 2 (file-level / upstream). + sys.exit(2) + except Exception as exc: # noqa: BLE001 + click.echo(f"UNEXPECTED ERROR: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + status = result.get("Status", "unknown") + details = result.get("Details") or [] + last_index = result.get("LastIndex", "?") + + # Print the OperationResult summary. + click.echo(f"Status: {status}") + click.echo(f"LastIndex: {last_index}") + click.echo(f"Details: {len(details)} item(s)") + for d in details: + seg = d.get("SegmentId", "?") + msg = d.get("Message", "") + dstatus = d.get("Status", "?") + click.echo(f" [{dstatus}] {seg}: {msg}") + + if status == "error": + click.echo(f"VALIDATION FAILED: {len(details)} error(s) in {input_file.name}", err=True) + sys.exit(2) + # success or warning — both exit 0 (warnings don't fail-closed). + return + + # --------------------------------------------------------------------------- # SP32: `cyclone backfill-rendering-npi` (Task 6) # diff --git a/backend/tests/test_validate_837_cli.py b/backend/tests/test_validate_837_cli.py new file mode 100644 index 0000000..c36659c --- /dev/null +++ b/backend/tests/test_validate_837_cli.py @@ -0,0 +1,125 @@ +"""SP40: tests for `cyclone validate-837 ` CLI.""" +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +from click.testing import CliRunner + +from cyclone import edifabric +from cyclone.cli import validate_837_cmd + + +_TEST_KEY = "test-edifabric-key-0123456789abcdef" + + +def _make_client(handler): + transport = httpx.MockTransport(handler) + return httpx.Client(transport=transport, timeout=10.0) + + +def _install(handler): + edifabric.set_transport_factory(lambda: _make_client(handler)) + + +@pytest.fixture(autouse=True) +def _reset_transport(): + yield + edifabric._reset_transport_factory() + + +# --- happy path ------------------------------------------------------- + + +def test_validate_837_success_prints_status_and_exits_zero(tmp_path): + """A clean file with Status='success' from Edifabric exits 0 and + prints the OperationResult summary.""" + fixture = Path("/home/tyler/dev/cyclone/backend/tests/fixtures/co_medicaid_837p.txt") + sample = tmp_path / "ok.x12" + sample.write_text(fixture.read_text()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/read"): + return httpx.Response(200, json=[{ + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000001"}, + "Groups": [], + "IEATrailers": [], + }]) + if request.url.path.endswith("/validate"): + return httpx.Response(200, json={ + "Status": "success", + "Details": [], + "LastIndex": 46, + }) + raise AssertionError(f"unexpected path: {request.url.path}") + + _install(handler) + runner = CliRunner() + result = runner.invoke(validate_837_cmd, [str(sample), "--api-key", _TEST_KEY]) + assert result.exit_code == 0, result.output + assert "Status: success" in result.output + assert "Details: 0 item(s)" in result.output + + +def test_validate_837_error_status_exits_two_and_prints_details(tmp_path): + """Status='error' from Edifabric exits 2 and prints the offending + segment + message for each Details item.""" + sample = tmp_path / "bad.x12" + sample.write_text("ISA*bad~") + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/read"): + return httpx.Response(200, json=[{ + "SegmentDelimiter": "~", + "DataElementDelimiter": "*", + "ISA": {"InterchangeControlNumber_13": "000000001"}, + "Groups": [], + "IEATrailers": [], + }]) + if request.url.path.endswith("/validate"): + return httpx.Response(200, json={ + "Status": "error", + "Details": [ + {"SegmentId": "PER", "Message": "PER-04 is required", "Status": "error"}, + {"SegmentId": "SBR", "Message": "SBR-09 is required", "Status": "error"}, + ], + "LastIndex": 5, + }) + raise AssertionError(f"unexpected path: {request.url.path}") + + _install(handler) + runner = CliRunner() + result = runner.invoke(validate_837_cmd, [str(sample), "--api-key", _TEST_KEY]) + assert result.exit_code == 2, result.output + assert "Status: error" in result.output + assert "PER: PER-04 is required" in result.output + assert "SBR: SBR-09 is required" in result.output + + +def test_validate_837_missing_api_key_exits_one(tmp_path, monkeypatch): + """With no API key anywhere (env var unset, keychain empty), the + CLI exits 1 and surfaces a clear 'API key not configured' error.""" + monkeypatch.delenv("CYCLONE_EDIFABRIC_API_KEY", raising=False) + sample = tmp_path / "any.x12" + sample.write_text("ISA*...~") + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport should not be called when key is missing") + + _install(handler) + runner = CliRunner() + result = runner.invoke(validate_837_cmd, [str(sample)]) + assert result.exit_code == 1, result.output + assert "API key not configured" in result.output + + +def test_validate_837_file_not_found_exits_two(): + """Click's path validation rejects a missing file before the + command body runs (exits 2 via Click's UsageError).""" + runner = CliRunner() + result = runner.invoke(validate_837_cmd, ["/nonexistent/path.x12", "--api-key", _TEST_KEY]) + assert result.exit_code == 2, result.output \ No newline at end of file