feat(sp35): /api/parse-835 envelope + empty-claims guards
Mirror of the parse-837 SP35 guards. Same defense-in-depth shape: tokenize first, reject anything whose ST01 doesn't start with '835' (400 'Mismatched file kind'), and after parse refuse to persist a batch with zero CLP segments (400 'No claims parsed'). Reuses the _transaction_set_id_from_segments helper added by the parse-837 commit. New tests in tests/test_api_835.py: - test_parse_835_endpoint_rejects_837_input (was failing, now green) - test_parse_835_endpoint_rejects_empty_envelope (was failing, now green) - test_parse_835_endpoint_happy_path_still_works (regression guard)
This commit is contained in:
@@ -671,6 +671,31 @@ async def parse_835_endpoint(
|
|||||||
|
|
||||||
config = _resolve_payer_835(payer)
|
config = _resolve_payer_835(payer)
|
||||||
|
|
||||||
|
# SP35 guard 1: envelope check. Mirrors the parse-837 path: tokenize,
|
||||||
|
# read ST01, reject anything that doesn't start with "835". Same
|
||||||
|
# defense-in-depth rationale — the UI auto-detect (src/pages/Upload.tsx)
|
||||||
|
# is layer A, but server-side guards protect every API caller.
|
||||||
|
try:
|
||||||
|
_segments_835 = _tokenize_segments(text)
|
||||||
|
detected_st_835 = _transaction_set_id_from_segments(_segments_835) or ""
|
||||||
|
except CycloneParseError:
|
||||||
|
detected_st_835 = ""
|
||||||
|
|
||||||
|
if detected_st_835 and not detected_st_835.upper().startswith("835"):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"error": "Mismatched file kind",
|
||||||
|
"expected": "835",
|
||||||
|
"detected_st": detected_st_835,
|
||||||
|
"detail": (
|
||||||
|
f"File declares ST*{detected_st_835}* but this endpoint "
|
||||||
|
f"expects ST*835*. Pick the matching endpoint on the "
|
||||||
|
f"Upload page (or let auto-detect choose for you)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = parse_835(text, config, input_file=file.filename or "")
|
result = parse_835(text, config, input_file=file.filename or "")
|
||||||
except CycloneParseError as exc:
|
except CycloneParseError as exc:
|
||||||
@@ -685,6 +710,21 @@ async def parse_835_endpoint(
|
|||||||
content={"error": "Internal server error", "detail": str(exc)},
|
content={"error": "Internal server error", "detail": str(exc)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SP35 guard 2: empty-claims check. Same as parse-837: a BatchRecord
|
||||||
|
# with claims=[] is never a valid production 835 batch and we refuse
|
||||||
|
# to persist it.
|
||||||
|
if not result.claims:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"error": "No claims parsed",
|
||||||
|
"detail": (
|
||||||
|
"The file passed the envelope check but contained no "
|
||||||
|
"CLP segments. Refusing to persist an empty batch."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Always run the validator; attach the report so the JSON path can
|
# Always run the validator; attach the report so the JSON path can
|
||||||
# surface it and the NDJSON path can fold the counts into the summary.
|
# surface it and the NDJSON path can fold the counts into the summary.
|
||||||
# 835 validation is batch-level, so pass/fail applies uniformly to every
|
# 835 validation is batch-level, so pass/fail applies uniformly to every
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from cyclone.store import store as global_store
|
|||||||
|
|
||||||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
||||||
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
|
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
|
||||||
|
FIXTURE_837P = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -337,3 +338,117 @@ def test_prodfile_round_trip_persists_separately(client: TestClient):
|
|||||||
# No duplicate PCNs survived the dedup; sanity check on persistence.
|
# No duplicate PCNs survived the dedup; sanity check on persistence.
|
||||||
pcns = [r["claimId"] for r in all_remits if r["claimId"]]
|
pcns = [r["claimId"] for r in all_remits if r["claimId"]]
|
||||||
assert len(pcns) == len(set(pcns)), "duplicate PCN across batches would be a persistence bug"
|
assert len(pcns) == len(set(pcns)), "duplicate PCN across batches would be a persistence bug"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# SP35: parse-835 input guards (mirror of the parse-837 guards)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
#
|
||||||
|
# Before SP35, /api/parse-835 had the same silent-corruption shape as
|
||||||
|
# /api/parse-837: any file with a parseable ISA envelope was accepted, and
|
||||||
|
# the 835 parser returned claims=[] when the file had no CLP segments.
|
||||||
|
# This produced empty 835 batches and bogus rows on the History tab.
|
||||||
|
# These tests are the server-layer regression locks for the 835 endpoint.
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_835_endpoint_rejects_837_input(client: TestClient):
|
||||||
|
"""Uploading an 837P file to /api/parse-835 must fail loudly, not persist.
|
||||||
|
|
||||||
|
Repro for the symmetric bug: user drops an 837P file on the Upload page
|
||||||
|
while the dropdown still says "835" (Upload.tsx default). Before SP35 the
|
||||||
|
endpoint accepted it, ran it through the 835 parser (which found zero
|
||||||
|
CLP segments), and persisted an empty claims=[] batch. SP35 closes the
|
||||||
|
door at the server so the UI bug becomes cosmetic instead of
|
||||||
|
data-corrupting.
|
||||||
|
"""
|
||||||
|
text_837p = FIXTURE_837P.read_text()
|
||||||
|
# Sanity check: the fixture really is an 837P file. If this ever flips,
|
||||||
|
# the test would still pass for the wrong reason.
|
||||||
|
assert "ST*837" in text_837p, "fixture is no longer ST*837 — update SP35 tests"
|
||||||
|
|
||||||
|
before = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
||||||
|
total_before = before.get("total", len(before.get("items", [])))
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-835",
|
||||||
|
files={"file": ("co_medicaid_837p.txt", text_837p, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error"] == "Mismatched file kind"
|
||||||
|
assert body["expected"] == "835"
|
||||||
|
assert body["detected_st"].startswith("837")
|
||||||
|
|
||||||
|
after = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
||||||
|
total_after = after.get("total", len(after.get("items", [])))
|
||||||
|
assert total_after == total_before, "Server persisted a batch from a 837P file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_835_endpoint_rejects_empty_envelope(client: TestClient):
|
||||||
|
"""Right envelope (ST*835), zero CLP segments → 400 No claims parsed.
|
||||||
|
|
||||||
|
Synthetic input: a complete ISA/GS/ST envelope with a BPR + TRN, a
|
||||||
|
closing SE/GE/IEA, and no CLP loops. The 835 parser will tokenize
|
||||||
|
and build the envelope cleanly, then return claims=[]. SP35 must
|
||||||
|
surface this as a 400 with error="No claims parsed" and must not
|
||||||
|
persist a batch.
|
||||||
|
"""
|
||||||
|
# Bare 835 envelope — no LX/CLP loops. A real X12 835 with no claims
|
||||||
|
# is unusual but possible (header-only test file or a cancelled run).
|
||||||
|
# The right behavior is to reject, not to silently persist.
|
||||||
|
synthetic = (
|
||||||
|
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER "
|
||||||
|
"*260617*1937*^*00501*000000001*0*P*:~"
|
||||||
|
"GS*HP*SENDER*RECEIVER*20260706*1937*1*X*005010X221A1~"
|
||||||
|
"ST*835*0001~"
|
||||||
|
"BPR*I*100.00*C*ACH*CCP*01*021000021*DA*123456*1512345678**01*021000021*DA*123456*20260706~"
|
||||||
|
"TRN*1*0001*1512345678~"
|
||||||
|
"N1*PR*PAYER NAME~"
|
||||||
|
"N3*123 PAYER ST~"
|
||||||
|
"N4*DENVER*CO*80202~"
|
||||||
|
"PER*BL*MEMBER SERVICES*TE*8005551212~"
|
||||||
|
"N1*PE*PAYEE NAME~"
|
||||||
|
"N3*456 PAYEE ST~"
|
||||||
|
"N4*DENVER*CO*80202~"
|
||||||
|
"REF*TJ*123456789~"
|
||||||
|
"SE*9*0001~"
|
||||||
|
"GE*1*1~"
|
||||||
|
"IEA*1*000000001~"
|
||||||
|
)
|
||||||
|
assert "ST*835" in synthetic
|
||||||
|
|
||||||
|
before = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
||||||
|
total_before = before.get("total", len(before.get("items", [])))
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-835",
|
||||||
|
files={"file": ("empty_835.txt", synthetic, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error"] == "No claims parsed"
|
||||||
|
|
||||||
|
after = client.get("/api/batches", headers={"Accept": "application/json"}).json()
|
||||||
|
total_after = after.get("total", len(after.get("items", [])))
|
||||||
|
assert total_after == total_before, "Server persisted an empty-claims 835 batch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_835_endpoint_happy_path_still_works(client: TestClient):
|
||||||
|
"""Regression guard: real 835 fixture must still parse → 200 with claims.
|
||||||
|
|
||||||
|
Sits next to the new SP35 rejection tests so any future tightening of
|
||||||
|
the guards that accidentally blocks the happy path fails here loudly.
|
||||||
|
"""
|
||||||
|
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 body.get("summary", {}).get("total_claims", 0) >= 1
|
||||||
|
|||||||
Reference in New Issue
Block a user