feat(parsers+api): 270/271 eligibility request + response (SP3 P4)
Adds API-only 270/271 eligibility flow per spec section 3.4 (no UI, no
DB persistence). Operator pastes a 270 query, gets back raw 270 text,
submits manually, pastes the 271 back.
New models (270 + 271):
- InformationSource, InformationReceiver, Subscriber, Patient
- EligibilityBenefitInquiry (EQ) and CoverageBenefit (EB)
- Inline SERVICE_TYPE_CODES dict (566 codes, snapshot 2026-06-20)
- service_type_description() lookup helper
Parsers (single segment walker, defensive, mirrors 835 pattern):
- parse_270: HL*20/21/22/23 loops + EQ, NM1, DMG, TRN
- parse_271: HL*20/21/22/23 loops + EB, DTP, MSG, AAA
Serializer:
- serialize_270: ISA/GS/ST/BHT/HL*20/21/22/patient/EQ/SE/GE/IEA
- round-trips: serialize -> parse -> serialize == identical
API:
- POST /api/eligibility/request (JSON body -> {raw_270_text, parsed})
- POST /api/eligibility/parse-271 (file upload -> structured summary)
- 400 on missing fields / garbage; never 500.
Tests: 365 -> 408 backend (+43 new), 10 frontend unchanged.
TypeScript clean.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Tests for the FastAPI surface in ``cyclone.api`` for the 270/271
|
||||
eligibility endpoints (SP3 P4 T23 + T24).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
from cyclone.parsers.parse_270 import parse as parse_270_text
|
||||
|
||||
MINIMAL_271 = Path(__file__).parent / "fixtures" / "minimal_271.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _valid_request_body() -> dict:
|
||||
return {
|
||||
"subscriber": {
|
||||
"first_name": "JOHN",
|
||||
"last_name": "DOE",
|
||||
"member_id": "MEMBER123",
|
||||
"dob": "1980-01-01",
|
||||
},
|
||||
"provider": {
|
||||
"npi": "1234567890",
|
||||
"name": "PROVIDER NAME",
|
||||
},
|
||||
"payer": {
|
||||
"id": "SKCO0",
|
||||
"name": "PAYER NAME",
|
||||
},
|
||||
"service_type_code": "1",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# T23: POST /api/eligibility/request
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_eligibility_request_returns_raw_270_text(client: TestClient):
|
||||
"""A minimal body returns the X12 270 text + the parsed structure."""
|
||||
body = _valid_request_body()
|
||||
resp = client.post("/api/eligibility/request", json=body)
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert "raw_270_text" in payload
|
||||
assert "parsed" in payload
|
||||
raw = payload["raw_270_text"]
|
||||
assert isinstance(raw, str) and raw
|
||||
# Canonical X12 envelope markers.
|
||||
assert raw.startswith("ISA*")
|
||||
assert raw.rstrip("\n").endswith("~IEA*1*000000001~")
|
||||
# All the required segments are present.
|
||||
assert "HL*1**20*1~" in raw
|
||||
assert "NM1*PR*2*PAYER NAME" in raw
|
||||
assert "HL*2*1*21*1~" in raw
|
||||
assert "NM1*1P*2*PROVIDER NAME" in raw
|
||||
assert "NM1*IL*1*DOE*JOHN" in raw
|
||||
assert "EQ*1~" in raw
|
||||
# The parsed payload mirrors the input.
|
||||
parsed = payload["parsed"]
|
||||
assert parsed["subscriber"]["first_name"] == "JOHN"
|
||||
assert parsed["subscriber"]["last_name"] == "DOE"
|
||||
assert parsed["subscriber"]["member_id"] == "MEMBER123"
|
||||
assert parsed["inquiries"][0]["service_type_code"] == "1"
|
||||
|
||||
|
||||
def test_eligibility_request_parsed_matches_serialized(client: TestClient):
|
||||
"""The endpoint's ``parsed`` field round-trips through a server-side
|
||||
re-parse of ``raw_270_text``.
|
||||
"""
|
||||
body = _valid_request_body()
|
||||
resp = client.post("/api/eligibility/request", json=body)
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
raw = payload["raw_270_text"]
|
||||
re_parsed = parse_270_text(raw, input_file="round_trip.txt")
|
||||
# The re-parsed structure must equal the endpoint's parsed payload
|
||||
# for the fields the operator can observe.
|
||||
parsed = payload["parsed"]
|
||||
assert re_parsed.subscriber.first_name == parsed["subscriber"]["first_name"]
|
||||
assert re_parsed.subscriber.last_name == parsed["subscriber"]["last_name"]
|
||||
assert re_parsed.subscriber.member_id == parsed["subscriber"]["member_id"]
|
||||
assert re_parsed.information_source.name == parsed["information_source"]["name"]
|
||||
assert re_parsed.information_receiver.npi == parsed["information_receiver"]["npi"]
|
||||
assert re_parsed.inquiries[0].service_type_code == parsed["inquiries"][0]["service_type_code"]
|
||||
|
||||
|
||||
def test_eligibility_request_missing_service_type_returns_400(client: TestClient):
|
||||
"""Missing ``service_type_code`` returns 400, never 500."""
|
||||
body = _valid_request_body()
|
||||
del body["service_type_code"]
|
||||
resp = client.post("/api/eligibility/request", json=body)
|
||||
assert resp.status_code == 400, resp.text
|
||||
detail = resp.json()["detail"]
|
||||
assert "service_type_code" in str(detail)
|
||||
|
||||
|
||||
def test_eligibility_request_missing_subscriber_member_id_returns_400(client: TestClient):
|
||||
"""Missing ``subscriber.member_id`` returns 400."""
|
||||
body = _valid_request_body()
|
||||
del body["subscriber"]["member_id"]
|
||||
resp = client.post("/api/eligibility/request", json=body)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# T24: POST /api/eligibility/parse-271
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_271_endpoint_happy_path(client: TestClient):
|
||||
"""Uploading the minimal 271 returns 200 + a non-empty
|
||||
``coverage_benefits`` array.
|
||||
"""
|
||||
text = MINIMAL_271.read_text()
|
||||
resp = client.post(
|
||||
"/api/eligibility/parse-271",
|
||||
files={"file": ("minimal_271.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "coverage_benefits" in body
|
||||
assert "subscriber" in body
|
||||
assert "summary" in body
|
||||
assert isinstance(body["coverage_benefits"], list)
|
||||
assert len(body["coverage_benefits"]) == 2
|
||||
# The first EB resolves to "Medical Care".
|
||||
cb0 = body["coverage_benefits"][0]
|
||||
assert cb0["service_type_code"] == "1"
|
||||
assert cb0["service_type_description"] == "Medical Care"
|
||||
assert cb0["in_plan_network"] is True
|
||||
# The second EB is pharmacy.
|
||||
cb1 = body["coverage_benefits"][1]
|
||||
assert cb1["service_type_code"] == "88"
|
||||
assert cb1["service_type_description"] == "Pharmacy"
|
||||
assert cb1["in_plan_network"] is False
|
||||
# Subscriber extracted.
|
||||
assert body["subscriber"]["first_name"] == "JOHN"
|
||||
assert body["subscriber"]["last_name"] == "DOE"
|
||||
assert body["subscriber"]["member_id"] == "MEMBER123"
|
||||
|
||||
|
||||
def test_parse_271_endpoint_invalid_edi_raises_400(client: TestClient):
|
||||
"""Garbage input must surface as 400, never 500."""
|
||||
resp = client.post(
|
||||
"/api/eligibility/parse-271",
|
||||
files={"file": ("garbage.txt", "not edi", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
body = resp.json()
|
||||
assert "error" in body
|
||||
|
||||
|
||||
def test_parse_271_endpoint_empty_file_returns_400(client: TestClient):
|
||||
"""An empty upload returns 400 (matches the 999 / 835 contract)."""
|
||||
resp = client.post(
|
||||
"/api/eligibility/parse-271",
|
||||
files={"file": ("empty.txt", "", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
Reference in New Issue
Block a user