a665881d24
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.
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""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
|