feat(sp40): cyclone validate-837 CLI + 4 CliRunner tests
This commit is contained in:
@@ -297,6 +297,71 @@ def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP40: `cyclone validate-837 <file>`
|
||||
#
|
||||
# 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)
|
||||
#
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""SP40: tests for `cyclone validate-837 <file>` 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
|
||||
Reference in New Issue
Block a user