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

235 lines
8.4 KiB
Python

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