b0e06a2dd0
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.
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""Tests for the /api/parse-ta1 endpoint and the ta1_acks list/detail endpoints."""
|
|
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 as global_store
|
|
from cyclone import db
|
|
|
|
|
|
ACCEPTED = (
|
|
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER "
|
|
"*260520*1750*^*00501*000000001*0*P*:~"
|
|
"TA1*000000001*20260520*1750*A*000*20260520~"
|
|
"IEA*1*000000001~"
|
|
)
|
|
|
|
REJECTED = (
|
|
"ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TP11525703 "
|
|
"*260520*1750*^*00501*000000001*0*P*:~"
|
|
"TA1*320293557*260520*2338*R*006~"
|
|
"IEA*0*000000001~"
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_store():
|
|
"""Reset the module-level store before and after each test."""
|
|
with global_store._lock:
|
|
global_store._batches.clear()
|
|
yield
|
|
with global_store._lock:
|
|
global_store._batches.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(app)
|
|
|
|
|
|
def test_parse_ta1_endpoint_happy_path(client: TestClient):
|
|
"""Accepted TA1 → 200 with parsed envelope + persisted ta1 row."""
|
|
resp = client.post(
|
|
"/api/parse-ta1",
|
|
files={"file": ("accepted.ta1", ACCEPTED, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
ta1 = body["ta1"]
|
|
assert ta1["ack_code"] == "A"
|
|
assert ta1["control_number"] == "000000001"
|
|
assert ta1["note_code"] == "000"
|
|
assert ta1["interchange_date"] == "2026-05-20"
|
|
assert ta1["source_batch_id"] == "TA1-000000001"
|
|
# Round-trip text present and well-formed.
|
|
assert ta1["raw_ta1_text"].startswith("TA1*")
|
|
assert ta1["raw_ta1_text"].endswith("~")
|
|
# Parsed shape mirrors the model.
|
|
assert body["parsed"]["ta1"]["ack_code"] == "A"
|
|
|
|
|
|
def test_parse_ta1_endpoint_rejected_persists(client: TestClient):
|
|
"""Rejected TA1 (Colorado YYMMDD format) → 200 + R ack_code in DB."""
|
|
resp = client.post(
|
|
"/api/parse-ta1",
|
|
files={"file": ("rejected.ta1", REJECTED, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["ta1"]["ack_code"] == "R"
|
|
assert body["ta1"]["note_code"] == "006"
|
|
# Persisted to DB
|
|
list_resp = client.get("/api/ta1-acks?limit=10", headers={"Accept": "application/json"})
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
list_body = list_resp.json()
|
|
assert list_body["total"] == 1
|
|
assert list_body["items"][0]["ack_code"] == "R"
|
|
|
|
|
|
def test_parse_ta1_endpoint_empty_file_returns_400(client: TestClient):
|
|
"""Empty upload → 400 (never 500)."""
|
|
resp = client.post(
|
|
"/api/parse-ta1",
|
|
files={"file": ("empty.ta1", b"", "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_parse_ta1_endpoint_malformed_returns_400(client: TestClient):
|
|
"""No TA1 segment → 400 with parse-error detail."""
|
|
text = (
|
|
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER "
|
|
"*260520*1750*^*00501*000000001*0*P*:~"
|
|
"IEA*0*000000001~"
|
|
)
|
|
resp = client.post(
|
|
"/api/parse-ta1",
|
|
files={"file": ("bad.ta1", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert "TA1" in body["detail"]
|
|
|
|
|
|
def test_parse_ta1_endpoint_rejects_missing_file(client: TestClient):
|
|
"""FastAPI File(...) with no default → 422."""
|
|
resp = client.post("/api/parse-ta1")
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_get_ta1_ack_detail_regenerates_segment(client: TestClient):
|
|
"""GET /api/ta1-acks/{id} includes the raw TA1 segment rebuilt from the row."""
|
|
resp = client.post(
|
|
"/api/parse-ta1",
|
|
files={"file": ("accepted.ta1", ACCEPTED, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200
|
|
ta1_id = resp.json()["ta1"]["id"]
|
|
|
|
detail_resp = client.get(f"/api/ta1-acks/{ta1_id}", headers={"Accept": "application/json"})
|
|
assert detail_resp.status_code == 200, detail_resp.text
|
|
body = detail_resp.json()
|
|
assert body["id"] == ta1_id
|
|
assert body["raw_ta1_text"].startswith("TA1*000000001*")
|
|
assert body["raw_ta1_text"].rstrip().endswith("~")
|
|
# raw_json carries the full ParseResultTa1 for the detail view.
|
|
assert body["raw_json"]["ta1"]["ack_code"] == "A"
|
|
|
|
|
|
def test_get_ta1_ack_404_for_missing(client: TestClient):
|
|
"""GET /api/ta1-acks/{id} returns 404 for a missing id."""
|
|
resp = client.get("/api/ta1-acks/9999", headers={"Accept": "application/json"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_list_ta1_acks_empty(client: TestClient):
|
|
"""Empty DB → empty list with total=0."""
|
|
resp = client.get("/api/ta1-acks", headers={"Accept": "application/json"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body == {"total": 0, "items": []}
|
|
|
|
|
|
def test_list_ta1_acks_newest_first(client: TestClient):
|
|
"""Multiple uploads: list returns rows newest first (by auto-increment id desc)."""
|
|
for i, payload in enumerate([ACCEPTED, REJECTED]):
|
|
resp = client.post(
|
|
"/api/parse-ta1",
|
|
files={"file": (f"file{i}.ta1", payload, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
resp = client.get("/api/ta1-acks", headers={"Accept": "application/json"})
|
|
items = resp.json()["items"]
|
|
assert len(items) == 2
|
|
# The REJECTED (uploaded second) is first.
|
|
assert items[0]["ack_code"] == "R"
|
|
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()
|