Files
cyclone/backend/tests/test_serialize_270.py
Tyler a665881d24 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.
2026-06-20 08:22:31 -06:00

210 lines
8.1 KiB
Python

"""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)