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:
+16
@@ -0,0 +1,16 @@
|
||||
ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID *240101*1200*^*00501*000000001*0*P*:~
|
||||
GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X279A1~
|
||||
ST*270*0001*005010X279A1~
|
||||
BHT*0022*13*REF123*20240101*1200~
|
||||
HL*1**20*1~
|
||||
NM1*PR*2*PAYER NAME*****PI*SKCO0~
|
||||
HL*2*1*21*1~
|
||||
NM1*1P*2*PROVIDER NAME*****XX*1234567890~
|
||||
HL*3*2*22*0~
|
||||
TRN*2*TRACE001~
|
||||
NM1*IL*1*DOE*JOHN*A***MI*MEMBER123~
|
||||
DMG*D8*19800101*M~
|
||||
EQ*1^30~
|
||||
SE*10*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000001~
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *240101*1200*^*00501*000000002*0*P*:~
|
||||
GS*HC*PAYERID*PROVIDERID*20240101*1200*2*X*005010X279A1~
|
||||
ST*271*0001*005010X279A1~
|
||||
BHT*0022*11*REF456*20240101*1200~
|
||||
HL*1**20*1~
|
||||
NM1*PR*2*PAYER NAME*****PI*SKCO0~
|
||||
HL*2*1*21*1~
|
||||
NM1*1P*2*PROVIDER NAME*****XX*1234567890~
|
||||
HL*3*2*22*0~
|
||||
TRN*2*TRACE001~
|
||||
NM1*IL*1*DOE*JOHN*A***MI*MEMBER123~
|
||||
DMG*D8*19800101*M~
|
||||
EB*1*IND*30**Y*1~
|
||||
MSG*Patient has active coverage~
|
||||
EB*88*IND**100*N**~
|
||||
MSG*Pharmacy copay $20~
|
||||
SE*13*0001~
|
||||
GE*1*2~
|
||||
IEA*1*000000002~
|
||||
@@ -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
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for the 270 (Eligibility Benefit Inquiry) Pydantic models.
|
||||
|
||||
SP3 Phase 4 (T18): minimal typed shape needed by the parser (T20),
|
||||
serializer (T22), and ``/api/eligibility/request`` endpoint (T23).
|
||||
The spec defines richer models (gender, address, etc.), but the v1
|
||||
round-trip only needs the minimum surface — see the inline rationale
|
||||
in ``parsers/models_270.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone.parsers.models import BatchSummary, Envelope
|
||||
from cyclone.parsers.models_270 import (
|
||||
EligibilityBenefitInquiry,
|
||||
InformationReceiver270,
|
||||
InformationSource270,
|
||||
ParseResult270,
|
||||
Patient270,
|
||||
Subscriber270,
|
||||
)
|
||||
|
||||
|
||||
def _build_result() -> ParseResult270:
|
||||
return ParseResult270(
|
||||
envelope=Envelope(
|
||||
sender_id="SUBMITTERID",
|
||||
receiver_id="RECEIVERID",
|
||||
control_number="000000001",
|
||||
transaction_date=date(2024, 1, 1),
|
||||
implementation_guide="005010X279A1",
|
||||
),
|
||||
information_source=InformationSource270(
|
||||
name="PAYER NAME",
|
||||
id="SKCO0",
|
||||
),
|
||||
information_receiver=InformationReceiver270(
|
||||
name="PROVIDER NAME",
|
||||
npi="1234567890",
|
||||
),
|
||||
subscriber=Subscriber270(
|
||||
member_id="MEMBER123",
|
||||
first_name="JOHN",
|
||||
last_name="DOE",
|
||||
dob=date(1980, 1, 1),
|
||||
),
|
||||
inquiries=[
|
||||
EligibilityBenefitInquiry(service_type_code="1"),
|
||||
EligibilityBenefitInquiry(service_type_code="30"),
|
||||
],
|
||||
summary=BatchSummary(
|
||||
input_file="minimal_270.txt",
|
||||
control_number="000000001",
|
||||
transaction_date=date(2024, 1, 1),
|
||||
total_claims=1,
|
||||
passed=1,
|
||||
failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_result_270_minimal_round_trip():
|
||||
"""Build a minimal ParseResult270 and round-trip via JSON."""
|
||||
r = _build_result()
|
||||
blob = json.loads(r.model_dump_json())
|
||||
assert blob["envelope"]["control_number"] == "000000001"
|
||||
assert blob["envelope"]["implementation_guide"] == "005010X279A1"
|
||||
assert blob["information_source"]["name"] == "PAYER NAME"
|
||||
assert blob["information_source"]["id"] == "SKCO0"
|
||||
assert blob["information_receiver"]["npi"] == "1234567890"
|
||||
assert blob["subscriber"]["first_name"] == "JOHN"
|
||||
assert blob["subscriber"]["last_name"] == "DOE"
|
||||
assert blob["subscriber"]["member_id"] == "MEMBER123"
|
||||
assert blob["subscriber"]["dob"] == "1980-01-01"
|
||||
assert blob["patient"] is None
|
||||
assert len(blob["inquiries"]) == 2
|
||||
assert blob["inquiries"][0]["service_type_code"] == "1"
|
||||
assert blob["inquiries"][1]["service_type_code"] == "30"
|
||||
|
||||
# Round-trip back to the model.
|
||||
r2 = ParseResult270.model_validate(blob)
|
||||
assert r2.information_source.name == "PAYER NAME"
|
||||
assert r2.subscriber.member_id == "MEMBER123"
|
||||
assert r2.inquiries[0].service_type_code == "1"
|
||||
assert r2.summary.total_claims == 1
|
||||
|
||||
|
||||
def test_eligibility_benefit_inquiry_required_fields():
|
||||
"""``service_type_code`` is the only required field on the EQ model."""
|
||||
# Without it: validation error.
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
EligibilityBenefitInquiry() # type: ignore[call-arg]
|
||||
assert "service_type_code" in str(exc.value)
|
||||
|
||||
# With it: parses cleanly. ``coverage_info`` is optional.
|
||||
ebi = EligibilityBenefitInquiry(service_type_code="1")
|
||||
assert ebi.coverage_info is None
|
||||
|
||||
# coverage_info supplied → round-trips.
|
||||
ebi2 = EligibilityBenefitInquiry(service_type_code="30", coverage_info="C")
|
||||
blob = json.loads(ebi2.model_dump_json())
|
||||
assert blob["coverage_info"] == "C"
|
||||
assert EligibilityBenefitInquiry.model_validate(blob).coverage_info == "C"
|
||||
|
||||
|
||||
def test_patient_270_optional_fields():
|
||||
"""Patient270's fields are all optional; relationship carries HL/INS qualifiers."""
|
||||
p = Patient270()
|
||||
blob = json.loads(p.model_dump_json())
|
||||
assert blob["first_name"] is None
|
||||
assert blob["relationship"] is None
|
||||
p2 = Patient270(first_name="JANE", last_name="DOE", relationship="19") # 19 = child
|
||||
assert p2.relationship == "19"
|
||||
|
||||
|
||||
def test_parse_result_270_inquiries_default_empty():
|
||||
"""Inquiries default to an empty list when omitted (Pydantic default_factory)."""
|
||||
r = ParseResult270(
|
||||
envelope=Envelope(
|
||||
sender_id="S", receiver_id="R", control_number="000000001",
|
||||
transaction_date=date(2024, 1, 1),
|
||||
),
|
||||
information_source=InformationSource270(name="P"),
|
||||
information_receiver=InformationReceiver270(name="P"),
|
||||
subscriber=Subscriber270(member_id="M"),
|
||||
summary=BatchSummary(input_file="", total_claims=0, passed=0, failed=0),
|
||||
)
|
||||
assert r.inquiries == []
|
||||
assert r.patient is None
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for the 271 (Eligibility Benefit Response) Pydantic models.
|
||||
|
||||
SP3 Phase 4 (T19): minimal typed shape needed by the parser (T21) and
|
||||
``/api/eligibility/parse-271`` endpoint (T24). Plus the inline
|
||||
``SERVICE_TYPE_CODES`` dict + ``service_type_description`` helper.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone.parsers.models import BatchSummary, Envelope
|
||||
from cyclone.parsers.models_271 import (
|
||||
LAST_UPDATED,
|
||||
SERVICE_TYPE_CODES,
|
||||
BenefitAdditionalInfo,
|
||||
CoverageBenefit,
|
||||
InformationReceiver271,
|
||||
InformationSource271,
|
||||
ParseResult271,
|
||||
Patient271,
|
||||
Subscriber271,
|
||||
service_type_description,
|
||||
)
|
||||
|
||||
|
||||
def _build_result() -> ParseResult271:
|
||||
return ParseResult271(
|
||||
envelope=Envelope(
|
||||
sender_id="PAYERID",
|
||||
receiver_id="PROVIDERID",
|
||||
control_number="000000002",
|
||||
transaction_date=date(2024, 1, 1),
|
||||
implementation_guide="005010X279A1",
|
||||
),
|
||||
information_source=InformationSource271(
|
||||
name="PAYER NAME",
|
||||
id="SKCO0",
|
||||
),
|
||||
information_receiver=InformationReceiver271(
|
||||
name="PROVIDER NAME",
|
||||
),
|
||||
subscriber=Subscriber271(
|
||||
member_id="MEMBER123",
|
||||
first_name="JOHN",
|
||||
last_name="DOE",
|
||||
),
|
||||
coverage_benefits=[
|
||||
CoverageBenefit(
|
||||
service_type_code="1",
|
||||
coverage_level="IND",
|
||||
in_plan_network=True,
|
||||
authorization_required=False,
|
||||
plan_coverage_description="Patient has active coverage",
|
||||
),
|
||||
CoverageBenefit(
|
||||
service_type_code="88",
|
||||
coverage_level="IND",
|
||||
benefit_amount=Decimal("100"),
|
||||
in_plan_network=False,
|
||||
authorization_required=True,
|
||||
),
|
||||
],
|
||||
summary=BatchSummary(
|
||||
input_file="minimal_271.txt",
|
||||
control_number="000000002",
|
||||
transaction_date=date(2024, 1, 1),
|
||||
total_claims=2,
|
||||
passed=2,
|
||||
failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_result_271_minimal_round_trip():
|
||||
"""Build a minimal ParseResult271 and round-trip via JSON."""
|
||||
r = _build_result()
|
||||
blob = json.loads(r.model_dump_json())
|
||||
assert blob["envelope"]["control_number"] == "000000002"
|
||||
assert blob["envelope"]["implementation_guide"] == "005010X279A1"
|
||||
assert blob["information_source"]["name"] == "PAYER NAME"
|
||||
assert blob["subscriber"]["first_name"] == "JOHN"
|
||||
assert blob["patient"] is None
|
||||
assert len(blob["coverage_benefits"]) == 2
|
||||
# Decimal serializes as a string in Pydantic v2 by default; verify
|
||||
# the round-trip preserves the value (not the str type).
|
||||
assert blob["coverage_benefits"][1]["benefit_amount"] == "100"
|
||||
|
||||
# Round-trip back to the model — Decimal must survive.
|
||||
r2 = ParseResult271.model_validate(blob)
|
||||
assert r2.coverage_benefits[1].benefit_amount == Decimal("100")
|
||||
assert r2.coverage_benefits[0].in_plan_network is True
|
||||
assert r2.coverage_benefits[1].authorization_required is True
|
||||
|
||||
|
||||
def test_service_type_description_known():
|
||||
"""``service_type_description("1")`` returns ``"Medical Care"``."""
|
||||
assert service_type_description("1") == "Medical Care"
|
||||
# Numeric codes with leading zero should not exist (X12 omits the
|
||||
# leading zero) but the lookup still works for whatever the caller
|
||||
# hands in.
|
||||
assert service_type_description("88") == "Pharmacy"
|
||||
assert service_type_description("AL") == "Vision (Optometry)"
|
||||
assert service_type_description("MH") == "Mental Health"
|
||||
assert service_type_description("UC") == "Urgent Care"
|
||||
# Case-insensitive: the parser may pass "1" or any case variant.
|
||||
assert service_type_description("mh") == "Mental Health"
|
||||
assert service_type_description("uc") == "Urgent Care"
|
||||
|
||||
|
||||
def test_service_type_description_unknown_returns_none():
|
||||
"""Unknown codes (and the empty string) return ``None``."""
|
||||
assert service_type_description("ZZZ") is None
|
||||
assert service_type_description("9999") is None
|
||||
assert service_type_description("") is None
|
||||
# Whitespace-only also returns None.
|
||||
assert service_type_description(" ") is None
|
||||
|
||||
|
||||
def test_service_type_codes_count_ge_50():
|
||||
"""The dict ships at least 50 service-type codes."""
|
||||
assert len(SERVICE_TYPE_CODES) >= 50
|
||||
# Every value must be a non-empty string.
|
||||
for code, label in SERVICE_TYPE_CODES.items():
|
||||
assert isinstance(code, str)
|
||||
assert code, "empty key"
|
||||
assert isinstance(label, str)
|
||||
assert label, f"empty label for {code!r}"
|
||||
|
||||
|
||||
def test_last_updated_constant():
|
||||
"""LAST_UPDATED is a YYYY-MM-DD string used to track snapshot refresh."""
|
||||
assert isinstance(LAST_UPDATED, str)
|
||||
assert re.match(r"^\d{4}-\d{2}-\d{2}$", LAST_UPDATED), (
|
||||
f"LAST_UPDATED must be YYYY-MM-DD, got {LAST_UPDATED!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_coverage_benefit_resolves_description_on_construction():
|
||||
"""``CoverageBenefit`` auto-populates ``service_type_description`` from
|
||||
``service_type_code`` when the caller omits it.
|
||||
"""
|
||||
cb = CoverageBenefit(service_type_code="1")
|
||||
assert cb.service_type_description == "Medical Care"
|
||||
# Caller-supplied description wins.
|
||||
cb2 = CoverageBenefit(service_type_code="1", service_type_description="Custom")
|
||||
assert cb2.service_type_description == "Custom"
|
||||
# Unknown code → description is None.
|
||||
cb3 = CoverageBenefit(service_type_code="ZZZ")
|
||||
assert cb3.service_type_description is None
|
||||
|
||||
|
||||
def test_coverage_benefit_round_trip_preserves_description():
|
||||
"""Round-tripping JSON does not overwrite a caller-supplied description."""
|
||||
r = _build_result()
|
||||
blob = json.loads(r.model_dump_json())
|
||||
# After JSON round-trip, the description we built (e.g. "Medical Care") survives.
|
||||
r2 = ParseResult271.model_validate(blob)
|
||||
assert r2.coverage_benefits[0].service_type_description == "Medical Care"
|
||||
assert r2.coverage_benefits[1].service_type_description == "Pharmacy"
|
||||
|
||||
|
||||
def test_required_service_type_code_on_coverage_benefit():
|
||||
"""``service_type_code`` is the only required field on CoverageBenefit."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
CoverageBenefit() # type: ignore[call-arg]
|
||||
assert "service_type_code" in str(exc.value)
|
||||
|
||||
# All other fields are optional / defaulted.
|
||||
cb = CoverageBenefit(service_type_code="1")
|
||||
assert cb.coverage_level is None
|
||||
assert cb.benefit_amount is None
|
||||
assert cb.in_plan_network is None
|
||||
assert cb.authorization_required is None
|
||||
assert cb.plan_coverage_description is None
|
||||
assert cb.time_qualifier is None
|
||||
assert cb.benefit_date is None
|
||||
assert cb.additional_info == []
|
||||
|
||||
|
||||
def test_benefit_additional_info_codes_default():
|
||||
"""``BenefitAdditionalInfo.codes`` defaults to an empty list."""
|
||||
bai = BenefitAdditionalInfo(description="Patient has active coverage")
|
||||
assert bai.codes == []
|
||||
bai2 = BenefitAdditionalInfo(
|
||||
description="Pharmacy copay $20",
|
||||
codes=["H0019", "H0020"],
|
||||
)
|
||||
assert bai2.codes == ["H0019", "H0020"]
|
||||
|
||||
|
||||
def test_patient_271_optional_fields():
|
||||
"""Patient271's fields are all optional; relationship is the only one with a
|
||||
v1 use (HL/INS qualifiers).
|
||||
"""
|
||||
p = Patient271()
|
||||
blob = json.loads(p.model_dump_json())
|
||||
assert blob == {
|
||||
"first_name": None,
|
||||
"last_name": None,
|
||||
"relationship": None,
|
||||
}
|
||||
p2 = Patient271(first_name="JANE", last_name="DOE", relationship="19")
|
||||
blob2 = json.loads(p2.model_dump_json())
|
||||
assert blob2["relationship"] == "19"
|
||||
|
||||
|
||||
def test_service_type_codes_contains_required_keys():
|
||||
"""Spot-check the codes the spec calls out as required."""
|
||||
required = {
|
||||
"1": "Medical Care",
|
||||
"2": "Surgical",
|
||||
"33": "Chiropractic",
|
||||
"35": "Dental Care",
|
||||
"47": "Hospital",
|
||||
"48": "Hospital - Inpatient",
|
||||
"50": "Hospital - Outpatient",
|
||||
"86": "Emergency Services",
|
||||
"88": "Pharmacy",
|
||||
"98": "Professional (Physician) Visit - Outpatient",
|
||||
"99": "Professional (Physician) Visit - Other",
|
||||
"AL": "Vision (Optometry)",
|
||||
"MH": "Mental Health",
|
||||
"UC": "Urgent Care",
|
||||
}
|
||||
for code, expected in required.items():
|
||||
assert SERVICE_TYPE_CODES.get(code) == expected, (
|
||||
f"missing or mismatched code {code!r}: got {SERVICE_TYPE_CODES.get(code)!r}, expected {expected!r}"
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for the 270 (Eligibility Benefit Inquiry) parser.
|
||||
|
||||
SP3 Phase 4 (T20): single-pass segment walker that pulls the
|
||||
information source / receiver / subscriber / patient / EQ inquiries
|
||||
out of a tokenized 270. The fixture (`tests/fixtures/minimal_270.txt`)
|
||||
is intentionally minimal: one EQ with a composite service-type code
|
||||
(``1^30``) and the four canonical HL loops.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.parse_270 import parse
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_270.txt"
|
||||
|
||||
|
||||
def test_parse_minimal_270_returns_inquiry():
|
||||
"""The fixture's EQ segment has the composite ``1^30`` — the parser
|
||||
keeps the whole string in ``service_type_code``.
|
||||
|
||||
v1 convention: callers that need the individual codes should split
|
||||
on ``^``. The serializer (T22) only ever emits a single code, so
|
||||
the round-trip will produce an exact match.
|
||||
"""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_270.txt")
|
||||
assert len(result.inquiries) == 1
|
||||
code = result.inquiries[0].service_type_code
|
||||
# Accept either the literal composite or the first component.
|
||||
assert code in {"1^30", "1"}
|
||||
if code == "1^30":
|
||||
first = code.split("^")[0]
|
||||
assert first in {"1"}
|
||||
|
||||
|
||||
def test_parse_270_subscriber_name_extracted():
|
||||
"""Subscriber first_name + last_name are pulled from NM1*IL."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_270.txt")
|
||||
assert result.subscriber.first_name == "JOHN"
|
||||
assert result.subscriber.last_name == "DOE"
|
||||
assert result.subscriber.member_id == "MEMBER123"
|
||||
# DMG segment — D8 = CCYYMMDD, M = male.
|
||||
assert result.subscriber.dob is not None
|
||||
assert result.subscriber.dob.year == 1980
|
||||
assert result.subscriber.dob.month == 1
|
||||
assert result.subscriber.dob.day == 1
|
||||
|
||||
|
||||
def test_parse_270_envelope_built():
|
||||
"""Envelope has sender_id, receiver_id, control_number, and the
|
||||
implementation guide from ST*03.
|
||||
"""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_270.txt")
|
||||
env = result.envelope
|
||||
assert env.sender_id.strip() == "SUBMITTERID"
|
||||
assert env.receiver_id.strip() == "RECEIVERID"
|
||||
assert env.control_number == "000000001"
|
||||
assert env.implementation_guide == "005010X279A1"
|
||||
# GS04 supplies the transaction date.
|
||||
assert env.transaction_date.year == 2024
|
||||
assert env.transaction_date.month == 1
|
||||
assert env.transaction_date.day == 1
|
||||
|
||||
|
||||
def test_parse_270_information_source_extracted():
|
||||
"""Information source (HL*20) pulls the payer name + id from NM1*PR."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_270.txt")
|
||||
assert result.information_source.name == "PAYER NAME"
|
||||
assert result.information_source.id == "SKCO0"
|
||||
# Information receiver (HL*21) pulls the provider + NPI.
|
||||
assert result.information_receiver.name == "PROVIDER NAME"
|
||||
assert result.information_receiver.npi == "1234567890"
|
||||
|
||||
|
||||
def test_parse_270_no_patient_when_no_hl23():
|
||||
"""``patient`` is None when the document has no HL*23 loop."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_270.txt")
|
||||
assert result.patient is None
|
||||
|
||||
|
||||
def test_parse_270_invalid_edi_raises():
|
||||
"""Garbage input must raise :class:`CycloneParseError` (not crash with
|
||||
a generic IndexError/KeyError).
|
||||
"""
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
|
||||
with pytest.raises(CycloneParseError):
|
||||
parse("not edi at all", input_file="garbage.txt")
|
||||
|
||||
|
||||
def test_parse_270_summary_counts_inquiries():
|
||||
"""The summary reports the number of EQ inquiries we parsed."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_270.txt")
|
||||
assert result.summary.total_claims == 1
|
||||
assert result.summary.passed == 1
|
||||
assert result.summary.failed == 0
|
||||
assert result.summary.input_file == "minimal_270.txt"
|
||||
assert result.summary.control_number == "000000001"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the 271 (Eligibility Benefit Response) parser.
|
||||
|
||||
SP3 Phase 4 (T21): single-pass segment walker that pulls the
|
||||
information source / receiver / subscriber / patient / EB coverage
|
||||
benefits out of a tokenized 271. The fixture
|
||||
(``tests/fixtures/minimal_271.txt``) has two EB segments with
|
||||
descriptions — one Medical Care, one Pharmacy — so we can assert
|
||||
both the count and the per-benefit resolution of service-type codes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.parse_271 import parse
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_271.txt"
|
||||
|
||||
|
||||
def test_parse_minimal_271_returns_two_benefits():
|
||||
"""The fixture's two EB segments become two CoverageBenefit rows."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert len(result.coverage_benefits) == 2
|
||||
# First EB: medical care (code "1"). Second: pharmacy (code "88").
|
||||
assert result.coverage_benefits[0].service_type_code == "1"
|
||||
assert result.coverage_benefits[1].service_type_code == "88"
|
||||
|
||||
|
||||
def test_parse_271_resolves_service_type_description():
|
||||
"""``service_type_description`` is auto-populated from the EB01 code."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.coverage_benefits[0].service_type_description == "Medical Care"
|
||||
assert result.coverage_benefits[1].service_type_description == "Pharmacy"
|
||||
|
||||
|
||||
def test_parse_271_in_plan_network_flag():
|
||||
"""The first EB has Y for in-plan, the second has N."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.coverage_benefits[0].in_plan_network is True
|
||||
assert result.coverage_benefits[1].in_plan_network is False
|
||||
|
||||
|
||||
def test_parse_271_envelope_built():
|
||||
"""Envelope has the ISA/GS/ST fields. ST03 = implementation guide."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
env = result.envelope
|
||||
assert env.sender_id.strip() == "PAYERID"
|
||||
assert env.receiver_id.strip() == "PROVIDERID"
|
||||
assert env.control_number == "000000002"
|
||||
assert env.implementation_guide == "005010X279A1"
|
||||
# GS04 supplies the transaction date.
|
||||
assert env.transaction_date.year == 2024
|
||||
assert env.transaction_date.month == 1
|
||||
assert env.transaction_date.day == 1
|
||||
|
||||
|
||||
def test_parse_271_msg_folded_into_additional_info():
|
||||
"""MSG segments become ``BenefitAdditionalInfo`` rows on the EB."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
cb0 = result.coverage_benefits[0]
|
||||
assert len(cb0.additional_info) == 1
|
||||
assert cb0.additional_info[0].description == "Patient has active coverage"
|
||||
cb1 = result.coverage_benefits[1]
|
||||
assert len(cb1.additional_info) == 1
|
||||
assert cb1.additional_info[0].description == "Pharmacy copay $20"
|
||||
|
||||
|
||||
def test_parse_271_subscriber_extracted():
|
||||
"""Subscriber first/last name + member id come from NM1*IL."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.subscriber.first_name == "JOHN"
|
||||
assert result.subscriber.last_name == "DOE"
|
||||
assert result.subscriber.member_id == "MEMBER123"
|
||||
|
||||
|
||||
def test_parse_271_invalid_edi_raises():
|
||||
"""Garbage input must raise :class:`CycloneParseError`."""
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
|
||||
with pytest.raises(CycloneParseError):
|
||||
parse("not edi at all", input_file="garbage.txt")
|
||||
|
||||
|
||||
def test_parse_271_summary_counts_eb_segments():
|
||||
"""The summary reports the number of EB segments we parsed."""
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, input_file="minimal_271.txt")
|
||||
assert result.summary.total_claims == 2
|
||||
assert result.summary.passed == 2
|
||||
assert result.summary.failed == 0
|
||||
assert result.summary.input_file == "minimal_271.txt"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tests for the 270 (Eligibility Benefit Inquiry) serializer.
|
||||
|
||||
SP3 Phase 4 (T22 + T25): build a ``ParseResult270``, serialize to
|
||||
X12, then re-parse and assert the round-trip is stable. The cross-
|
||||
parser round-trip test (T25) lives at the bottom of this file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.models import BatchSummary, Envelope
|
||||
from cyclone.parsers.models_270 import (
|
||||
EligibilityBenefitInquiry,
|
||||
InformationReceiver270,
|
||||
InformationSource270,
|
||||
ParseResult270,
|
||||
Subscriber270,
|
||||
)
|
||||
from cyclone.parsers.parse_270 import parse
|
||||
from cyclone.parsers.serialize_270 import serialize_270
|
||||
|
||||
|
||||
def _build_result(
|
||||
service_type_code: str = "1",
|
||||
with_patient: bool = False,
|
||||
) -> ParseResult270:
|
||||
patient = None
|
||||
if with_patient:
|
||||
from cyclone.parsers.models_270 import Patient270
|
||||
patient = Patient270(
|
||||
first_name="JANE",
|
||||
last_name="DOE",
|
||||
dob=date(2010, 5, 5),
|
||||
relationship="19",
|
||||
)
|
||||
return ParseResult270(
|
||||
envelope=Envelope(
|
||||
sender_id="SUBMITTERID",
|
||||
receiver_id="RECEIVERID",
|
||||
control_number="000000001",
|
||||
transaction_date=date(2024, 1, 1),
|
||||
implementation_guide="005010X279A1",
|
||||
),
|
||||
information_source=InformationSource270(name="PAYER NAME", id="SKCO0"),
|
||||
information_receiver=InformationReceiver270(name="PROVIDER NAME", npi="1234567890"),
|
||||
subscriber=Subscriber270(
|
||||
member_id="MEMBER123",
|
||||
first_name="JOHN",
|
||||
last_name="DOE",
|
||||
dob=date(1980, 1, 1),
|
||||
),
|
||||
patient=patient,
|
||||
inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)],
|
||||
summary=BatchSummary(
|
||||
input_file="round_trip.txt",
|
||||
total_claims=1,
|
||||
passed=1,
|
||||
failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_270_envelope_segments():
|
||||
"""Output starts with ``ISA*`` and ends with ``IEA*``; canonical envelope
|
||||
layers are all present in order.
|
||||
"""
|
||||
text = serialize_270(_build_result())
|
||||
assert text.startswith("ISA*")
|
||||
# Strip trailing newline if any, then check the IEA tail.
|
||||
body = text.rstrip("\n")
|
||||
assert body.endswith("~IEA*1*000000001~")
|
||||
# Sender/receiver ids in the GS are padded to 15 chars (X12 fixed width).
|
||||
assert "GS*HC*SUBMITTERID *RECEIVERID *" in text
|
||||
assert "ST*270*0001*005010X279A1~" in text
|
||||
assert "BHT*0022*13*" in text
|
||||
assert "HL*1**20*1~" in text
|
||||
assert "NM1*PR*2*PAYER NAME" in text
|
||||
assert "HL*2*1*21*1~" in text
|
||||
assert "NM1*1P*2*PROVIDER NAME" in text
|
||||
assert "HL*3*2*22*0~" in text
|
||||
assert "TRN*2*TRACE001~" in text
|
||||
assert "NM1*IL*1*DOE*JOHN" in text
|
||||
assert "DMG*D8*19800101" in text
|
||||
assert "SE*" in text
|
||||
assert "GE*1*1 ~" in text # group_control_number padded to 9 chars
|
||||
assert "IEA*1*000000001~" in text
|
||||
|
||||
|
||||
def test_serialize_270_eq_segment_present():
|
||||
"""Output contains a single ``EQ*`` segment carrying the service type code."""
|
||||
text = serialize_270(_build_result(service_type_code="30"))
|
||||
assert "EQ*30~" in text
|
||||
|
||||
|
||||
def test_serialize_270_minimal_round_trip():
|
||||
"""Build → serialize → parse → assert fields match (envelope + subscriber + EQ)."""
|
||||
original = _build_result(service_type_code="1")
|
||||
text = serialize_270(original)
|
||||
re_parsed = parse(text, input_file="round_trip.txt")
|
||||
|
||||
# Envelope
|
||||
assert re_parsed.envelope.sender_id.strip() == original.envelope.sender_id
|
||||
assert re_parsed.envelope.receiver_id.strip() == original.envelope.receiver_id
|
||||
assert re_parsed.envelope.control_number == "000000001"
|
||||
assert re_parsed.envelope.implementation_guide == "005010X279A1"
|
||||
|
||||
# Information source / receiver
|
||||
assert re_parsed.information_source.name == "PAYER NAME"
|
||||
assert re_parsed.information_source.id == "SKCO0"
|
||||
assert re_parsed.information_receiver.name == "PROVIDER NAME"
|
||||
assert re_parsed.information_receiver.npi == "1234567890"
|
||||
|
||||
# Subscriber
|
||||
assert re_parsed.subscriber.first_name == "JOHN"
|
||||
assert re_parsed.subscriber.last_name == "DOE"
|
||||
assert re_parsed.subscriber.member_id == "MEMBER123"
|
||||
assert re_parsed.subscriber.dob == date(1980, 1, 1)
|
||||
|
||||
# Inquiry
|
||||
assert len(re_parsed.inquiries) == 1
|
||||
assert re_parsed.inquiries[0].service_type_code == "1"
|
||||
|
||||
|
||||
def test_serialize_270_with_patient_round_trip():
|
||||
"""Build a result with a Patient270, serialize, re-parse, assert the
|
||||
patient loop comes back.
|
||||
"""
|
||||
original = _build_result(with_patient=True)
|
||||
text = serialize_270(original)
|
||||
re_parsed = parse(text, input_file="round_trip_with_patient.txt")
|
||||
assert re_parsed.patient is not None
|
||||
assert re_parsed.patient.first_name == "JANE"
|
||||
assert re_parsed.patient.last_name == "DOE"
|
||||
assert re_parsed.patient.dob == date(2010, 5, 5)
|
||||
|
||||
|
||||
def test_serialize_270_custom_interchange_control_number():
|
||||
"""A custom interchange control number propagates into the ISA and IEA segments."""
|
||||
text = serialize_270(_build_result(), interchange_control_number="000000042")
|
||||
assert "ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID" in text
|
||||
# IEA*1*<ICN>~ at the end.
|
||||
assert text.rstrip("\n").endswith("~IEA*1*000000042~")
|
||||
# ISA also carries it.
|
||||
assert "*000000042*" in text.split("~", 1)[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# T25 — Cross-parser round-trip (full)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_cross_parser_270_round_trip():
|
||||
"""Build a fully populated ParseResult270, serialize, parse, assert each
|
||||
field matches the original (except the raw envelope control_number
|
||||
which the serializer regenerates from defaults).
|
||||
"""
|
||||
original = _build_result(
|
||||
service_type_code="88", with_patient=True,
|
||||
)
|
||||
text = serialize_270(original)
|
||||
re_parsed = parse(text, input_file="cross.txt")
|
||||
|
||||
# Envelope — sender/receiver/guide are stable; control_number is regenerated
|
||||
# by the serializer's default "000000001" (the parser pulls it from ISA13).
|
||||
assert re_parsed.envelope.control_number == "000000001"
|
||||
assert re_parsed.envelope.sender_id.strip() == "SUBMITTERID"
|
||||
assert re_parsed.envelope.receiver_id.strip() == "RECEIVERID"
|
||||
assert re_parsed.envelope.implementation_guide == "005010X279A1"
|
||||
|
||||
# Information source + receiver
|
||||
assert re_parsed.information_source.name == original.information_source.name
|
||||
assert re_parsed.information_source.id == original.information_source.id
|
||||
assert re_parsed.information_receiver.name == original.information_receiver.name
|
||||
assert re_parsed.information_receiver.npi == original.information_receiver.npi
|
||||
|
||||
# Subscriber
|
||||
assert re_parsed.subscriber.member_id == original.subscriber.member_id
|
||||
assert re_parsed.subscriber.first_name == original.subscriber.first_name
|
||||
assert re_parsed.subscriber.last_name == original.subscriber.last_name
|
||||
assert re_parsed.subscriber.dob == original.subscriber.dob
|
||||
|
||||
# Patient (set on the original).
|
||||
assert re_parsed.patient is not None
|
||||
assert re_parsed.patient.first_name == original.patient.first_name
|
||||
assert re_parsed.patient.last_name == original.patient.last_name
|
||||
assert re_parsed.patient.dob == original.patient.dob
|
||||
|
||||
# Inquiries
|
||||
assert len(re_parsed.inquiries) == len(original.inquiries)
|
||||
for got, exp in zip(re_parsed.inquiries, original.inquiries):
|
||||
assert got.service_type_code == exp.service_type_code
|
||||
|
||||
# Re-serializing the parsed result must be identical to the first
|
||||
# serialization (idempotent round-trip — except for BHT03 which is
|
||||
# timestamp-based, and ISA09/10/GS04/05 which use the current date).
|
||||
text2 = serialize_270(re_parsed)
|
||||
# Normalize both texts to drop the date/time-driven segments before
|
||||
# comparing for "shape" stability: we keep envelope, HL/NM1, EQ.
|
||||
def _shape(t: str) -> list[str]:
|
||||
return [
|
||||
s for s in t.split("~")
|
||||
if s and not s.startswith("BHT") and not s.startswith("ISA")
|
||||
and not s.startswith("GS") and not s.startswith("GE")
|
||||
and not s.startswith("IEA")
|
||||
]
|
||||
|
||||
assert _shape(text) == _shape(text2)
|
||||
Reference in New Issue
Block a user