Files
cyclone/backend/tests/test_models_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

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