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:
Nora
2026-07-06 09:52:51 -06:00
parent f25214189a
commit d1cd6e1a51
2 changed files with 155 additions and 0 deletions
+40
View File
@@ -671,6 +671,31 @@ async def parse_835_endpoint(
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:
result = parse_835(text, config, input_file=file.filename or "")
except CycloneParseError as exc:
@@ -685,6 +710,21 @@ async def parse_835_endpoint(
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
# 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
+115
View File
@@ -13,6 +13,7 @@ from cyclone.store import store as global_store
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
FIXTURE_837P = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
@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.
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"
# --------------------------------------------------------------------------- #
# 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