feat(backend): 835 ERA parser with BPR/CLP/SVC balancing, CO Medicaid config, CLI, and FastAPI route
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""Tests for the FastAPI surface in ``cyclone.api`` for the 835 ERA endpoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
||||
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JSON response path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_835_endpoint_returns_json(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "envelope" in body
|
||||
assert "financial_info" in body
|
||||
assert "trace" in body
|
||||
assert "payer" in body
|
||||
assert "payee" in body
|
||||
assert "claims" in body and len(body["claims"]) == 2
|
||||
assert "summary" in body
|
||||
assert body["summary"]["total_claims"] == 2
|
||||
assert body["summary"]["passed"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# NDJSON streaming path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_835_endpoint_streams_ndjson(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||
|
||||
lines = list(resp.iter_lines())
|
||||
# 1 envelope + 1 financial_info + 1 trace + 1 payer + 1 payee + 2 claims + 1 summary = 8
|
||||
assert len(lines) == 8
|
||||
|
||||
parsed = [json.loads(line) for line in lines]
|
||||
assert parsed[0]["type"] == "envelope"
|
||||
assert parsed[1]["type"] == "financial_info"
|
||||
assert parsed[2]["type"] == "trace"
|
||||
assert parsed[3]["type"] == "payer"
|
||||
assert parsed[4]["type"] == "payee"
|
||||
assert parsed[5]["type"] == "claim_payment"
|
||||
assert parsed[6]["type"] == "claim_payment"
|
||||
assert parsed[7]["type"] == "summary"
|
||||
|
||||
# When include_raw_segments defaults to True, each claim carries raw segments.
|
||||
for obj in parsed[5:7]:
|
||||
assert "raw_segments" in obj["data"]
|
||||
assert isinstance(obj["data"]["raw_segments"], list)
|
||||
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[7]["data"]["total_claims"] == 2
|
||||
assert parsed[7]["data"]["passed"] == 1
|
||||
|
||||
|
||||
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835?include_raw_segments=false",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||
|
||||
claims = [
|
||||
json.loads(line)
|
||||
for line in resp.iter_lines()
|
||||
if json.loads(line)["type"] == "claim_payment"
|
||||
]
|
||||
assert len(claims) == 2
|
||||
for c in claims:
|
||||
assert c["data"]["raw_segments"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation / error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_835_endpoint_rejects_missing_file(client: TestClient):
|
||||
# FastAPI's `File(...)` (no default) → 422 Unprocessable Entity.
|
||||
resp = client.post("/api/parse-835")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_parse_835_endpoint_handles_payer_query_param(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
for payer in ("co_medicaid_835", "generic_835"):
|
||||
resp = client.post(
|
||||
f"/api/parse-835?payer={payer}",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, (payer, resp.text)
|
||||
body = resp.json()
|
||||
assert body["summary"]["total_claims"] == 2
|
||||
assert body["summary"]["passed"] == 1
|
||||
|
||||
|
||||
def test_parse_835_endpoint_rejects_unknown_payer(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835?payer=does_not_exist",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_parse_835_endpoint_unbalanced_returns_422(client: TestClient):
|
||||
text = UNBALANCED.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": ("unbalanced_835.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
body = resp.json()
|
||||
assert body["summary"]["failed"] == 1
|
||||
# The R835_BAL_BPR_vs_CLP04 rule should have fired.
|
||||
assert any(
|
||||
issue["rule"] == "R835_BAL_BPR_vs_CLP04"
|
||||
for issue in body["validation"]["errors"]
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CORS
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_cors_headers_present_for_835(client: TestClient):
|
||||
resp = client.options(
|
||||
"/api/parse-835",
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||
Reference in New Issue
Block a user