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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user