Files
cyclone/backend/tests/test_api_999.py
T
Nora b0e06a2dd0 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.
2026-07-06 09:53:53 -06:00

194 lines
7.1 KiB
Python

"""Tests for the FastAPI surface in ``cyclone.api`` for the 999 endpoint."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
@pytest.fixture(autouse=True)
def clear_store():
"""Reset the module-level store before and after each test."""
with store._lock:
store._batches.clear()
yield
with store._lock:
store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_parse_999_endpoint_happy_path(client: TestClient):
"""Uploading the minimal accepted 999 returns 200 + an acks row."""
assert len(store.list_acks()) == 0
text = ACCEPTED.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "ack" in body
assert "parsed" in body
ack = body["ack"]
assert ack["accepted_count"] == 1
assert ack["rejected_count"] == 0
assert ack["received_count"] == 1
assert ack["ack_code"] == "A"
# The raw 999 text round-trips.
assert "ISA*" in ack["raw_999_text"]
assert "AK9*A*1*1*0~" in ack["raw_999_text"]
# One row persisted.
rows = store.list_acks()
assert len(rows) == 1
assert rows[0].ack_code == "A"
def test_parse_999_endpoint_invalid_edi_raises_400(client: TestClient):
"""Garbage input must surface as 400, never 500."""
resp = client.post(
"/api/parse-999",
files={"file": ("garbage.txt", "not edi", "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
assert "error" in resp.json()
def test_parse_999_endpoint_empty_file_raises_400(client: TestClient):
"""An empty upload must be rejected with 400 (matches the 835 path)."""
resp = client.post(
"/api/parse-999",
files={"file": ("empty.txt", "", "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 400, resp.text
def test_parse_999_persists_acks_row(client: TestClient):
"""After a parse-999 call, GET /api/acks returns the row."""
text = REJECTED.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("rejected_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
# The new GET endpoint must surface the row.
list_resp = client.get("/api/acks", headers={"Accept": "application/json"})
assert list_resp.status_code == 200, list_resp.text
body = list_resp.json()
assert body["total"] == 1
assert body["items"][0]["ack_code"] == "R"
assert body["items"][0]["accepted_count"] == 0
assert body["items"][0]["rejected_count"] == 1
def test_parse_999_rejected_fixture_surfaces_r_code(client: TestClient):
"""The rejected 999 fixture must produce an R ack_code."""
text = REJECTED.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("rejected_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200
body = resp.json()
assert body["ack"]["ack_code"] == "R"
assert body["ack"]["rejected_count"] == 1
assert body["ack"]["accepted_count"] == 0
def test_get_ack_detail_regenerates_x12_text(client: TestClient) -> None:
"""SP3 P3 follow-up: GET /api/acks/{id} must include `raw_999_text`
regenerated from raw_json so the operator can actually download the
999 file. The P3 subagent shipped `raw_999_text: None` as a stub."""
text = REJECTED.read_text()
post_resp = client.post(
"/api/parse-999",
files={"file": ("rejected_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert post_resp.status_code == 200
ack_id = post_resp.json()["ack"]["id"]
detail_resp = client.get(f"/api/acks/{ack_id}", headers={"Accept": "application/json"})
assert detail_resp.status_code == 200, detail_resp.text
body = detail_resp.json()
# raw_999_text must be present and non-empty X12.
assert "raw_999_text" in body
assert body["raw_999_text"] is not None
assert body["raw_999_text"].startswith("ISA*")
assert "AK1*" in body["raw_999_text"]
assert "AK9*" in body["raw_999_text"]
# IEA segment exists in output; serializer writes `IEA*1*<icn>~`.
assert "IEA*" in body["raw_999_text"]
assert body["raw_999_text"].rstrip().endswith("~")
def test_get_ack_404_for_missing(client: TestClient) -> None:
"""GET /api/acks/{id} returns 404 for a missing id (not 500)."""
resp = client.get("/api/acks/9999", headers={"Accept": "application/json"})
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()