feat(sp35): regression locks on parse-999/277ca/ta1 envelope guards

The 999, 277CA, and TA1 parsers already enforce envelope correctness at
the parser level (parse_999.py line 290 raises 'No AK9 segment found';
parse_277ca.py line 298 raises 'Expected ST*277 or ST*277CA'; parse_ta1.py
line 111 raises 'Expected TA1, got <other>'). These tests lock the HTTP
surface contract: a wrong-kind file POSTed to those endpoints must come
back as 400, never as 200 or 500.

Tests added:
- test_api_999.py: rejects_837_input, rejects_835_input
- test_api_277ca.py: rejects_835_input, rejects_837_input
- test_api_ta1.py: rejects_837_input, rejects_835_input

If a future PR relaxes any of those parser-level guards, the
corresponding regression lock fires immediately.
This commit is contained in:
Nora
2026-07-06 09:53:53 -06:00
parent d1cd6e1a51
commit b0e06a2dd0
3 changed files with 147 additions and 1 deletions
+45
View File
@@ -167,3 +167,48 @@ class TestInboxPayerRejectedLane:
# The rejected lane (999 envelope) must be empty — we haven't # The rejected lane (999 envelope) must be empty — we haven't
# uploaded a 999, so this claim isn't there. # uploaded a 999, so this claim isn't there.
assert "c1" not in [c["id"] for c in lanes["rejected"]] assert "c1" not in [c["id"] for c in lanes["rejected"]]
# --------------------------------------------------------------------------- #
# SP35: parse-277ca envelope regression lock
# --------------------------------------------------------------------------- #
#
# The 277CA parser already raises CycloneParseError("Expected ST*277 or
# ST*277CA, got ST*<other>") when fed a file with the wrong ST envelope
# (parse_277ca.py line 298). This regression lock confirms the HTTP
# surface converts that error into a 400 (never 200, never 500).
def test_parse_277ca_endpoint_rejects_835_input(client: TestClient):
"""Posting an 835 file to /api/parse-277ca must surface 400, not 200."""
wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
text = wrong_kind.read_text()
assert "ST*835" in text # sanity check on the fixture
resp = client.post(
"/api/parse-277ca",
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
body = resp.json()
assert "error" in body
# The parser-level message must survive (mentions "ST" and the
# expected vs actual set id).
detail = body.get("detail", "")
assert "ST" in detail and ("277" in detail or body["error"] == "Parse error"), body
def test_parse_277ca_endpoint_rejects_837_input(client: TestClient):
"""Posting an 837P file to /api/parse-277ca must surface 400, not 200."""
wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
text = wrong_kind.read_text()
assert "ST*837" in text # sanity check
resp = client.post(
"/api/parse-277ca",
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
assert "error" in resp.json()
+51
View File
@@ -140,3 +140,54 @@ def test_get_ack_404_for_missing(client: TestClient) -> None:
"""GET /api/acks/{id} returns 404 for a missing id (not 500).""" """GET /api/acks/{id} returns 404 for a missing id (not 500)."""
resp = client.get("/api/acks/9999", headers={"Accept": "application/json"}) resp = client.get("/api/acks/9999", headers={"Accept": "application/json"})
assert resp.status_code == 404 assert resp.status_code == 404
# --------------------------------------------------------------------------- #
# SP35: parse-999 envelope regression lock
# --------------------------------------------------------------------------- #
#
# The 999 parser already raises CycloneParseError("No AK9 (Functional Group
# Response Status) segment found") when fed a non-999 file (parse_999.py
# line 290). This regression lock confirms the HTTP surface converts that
# error into a 400 (never 200, never 500) so a misroute upload fails loudly
# instead of silently creating a corrupt ack row.
def test_parse_999_endpoint_rejects_837_input(client: TestClient):
"""Posting an 837P file to /api/parse-999 must surface 400, not 200.
Before SP35, the 999 parser's envelope guard (no AK9) was already
strict at the parser level. This test makes the HTTP contract
explicit: a wrong-kind file POSTed to the 999 endpoint MUST come
back as 400, not as 200 with an empty ack.
"""
wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
text = wrong_kind.read_text()
assert "ST*837" in text # sanity check on the fixture
resp = client.post(
"/api/parse-999",
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
body = resp.json()
assert "error" in body
# The parser-level message must survive (not be replaced by a generic
# "Internal server error" or similar).
assert "AK9" in body.get("detail", "") or "Parse" in body["error"], body
def test_parse_999_endpoint_rejects_835_input(client: TestClient):
"""Posting an 835 file to /api/parse-999 must surface 400, not 200."""
wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
text = wrong_kind.read_text()
assert "ST*835" in text # sanity check
resp = client.post(
"/api/parse-999",
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
assert "error" in resp.json()
+51 -1
View File
@@ -163,4 +163,54 @@ def test_list_ta1_acks_newest_first(client: TestClient):
assert len(items) == 2 assert len(items) == 2
# The REJECTED (uploaded second) is first. # The REJECTED (uploaded second) is first.
assert items[0]["ack_code"] == "R" assert items[0]["ack_code"] == "R"
assert items[1]["ack_code"] == "A" assert items[1]["ack_code"] == "A"
# --------------------------------------------------------------------------- #
# SP35: parse-ta1 envelope regression lock
# --------------------------------------------------------------------------- #
#
# The TA1 parser already raises CycloneParseError("Expected TA1, got <other>")
# when fed a file that doesn't have a TA1 segment as its first payload
# segment (parse_ta1.py line 111). This regression lock confirms the HTTP
# surface converts that error into a 400 (never 200, never 500). TA1 has
# no ST envelope, so the test uses an 837 fixture (which has ISA + GS +
# ST*837 but no TA1 segment) to exercise the parser-level guard.
def test_parse_ta1_endpoint_rejects_837_input(client: TestClient):
"""Posting an 837P file to /api/parse-ta1 must surface 400, not 200.
The TA1 envelope has no ST (it's the bare interchange-ack segment),
so the wrong-kind check is structural — the parser looks for the TA1
segment and raises when it doesn't find one. An 837 file has ISA +
GS + ST*837 but no TA1, which triggers that branch.
"""
wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
text = wrong_kind.read_text()
assert "ST*837" in text # sanity check on the fixture
resp = client.post(
"/api/parse-ta1",
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
body = resp.json()
assert "error" in body
detail = body.get("detail", "")
assert "TA1" in detail or body["error"] == "Parse error", body
def test_parse_ta1_endpoint_rejects_835_input(client: TestClient):
"""Posting an 835 file to /api/parse-ta1 must surface 400, not 200."""
wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
text = wrong_kind.read_text()
assert "ST*835" in text # sanity check
resp = client.post(
"/api/parse-ta1",
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
assert "error" in resp.json()