1942a22629
Adds pure local validators for the 10-digit NPI Luhn checksum (CMS- published algorithm with the '80840' NPPES prefix) and 9-digit EIN format (rejects reserved prefixes 00/07/80-89). No NPPES round-trip, no IRS e-file lookup — catches the 99% typo case at parse time. Surface: - cyclone.npi.is_valid_npi / is_valid_tax_id / normalize_tax_id - CLI: 'cyclone validate-npi <npi>' and 'cyclone validate-tax-id <ein>' - API: GET /api/admin/validate-provider?npi=&tax_id= - Parser validator: new R021_npi_checksum rule (warning, not error, to keep test fixtures with placeholder NPIs ingestible) - minimal_837p.txt fixture NPI updated from '1234567890' to the Luhn-valid '1993999998' so strict-mode CLI parses still pass Tests: - test_npi.py — 27 cases (Luhn math, valid/invalid NPIs, EIN cases, normalize_tax_id edge cases) - test_api_validate_provider.py — 4 cases (both valid, both invalid, omitted NPI, omitted tax_id) - test_cli_validate.py — 8 cases (valid/invalid for both subcommands, exit codes, malformed inputs) - test_validator.py — 4 new R021 cases (valid Luhn silent, bad Luhn warning, skipped when format bad, skipped when NPI missing) Total: 923 tests pass.
199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
"""Tests for ``cyclone.npi`` — NPI checksum + Tax ID (EIN) format validation.
|
||
|
||
Pure local validation, no NPPES calls. Covers the NPPES-published Luhn
|
||
checksum (with prefix ``80840``) and a small set of obvious EIN typo cases.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from cyclone.npi import (
|
||
_luhn_check_digit,
|
||
is_valid_npi,
|
||
is_valid_tax_id,
|
||
npi_checksum,
|
||
normalize_tax_id,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Luhn internals
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_luhn_check_digit_known_sequence():
|
||
"""Standard Luhn for the empty body returns 0.
|
||
|
||
With no digits the sum is 0, so (10 - 0 % 10) % 10 = 0.
|
||
"""
|
||
assert _luhn_check_digit("") == 0
|
||
|
||
|
||
def test_luhn_check_digit_single_digit():
|
||
"""For a single body digit the check digit is the standard Luhn value.
|
||
|
||
With the corrected "double at rightmost" pattern: "0" doubles to 0
|
||
(total=0, check=0); "1" doubles to 2 (total=2, check=(10-2)%10=8).
|
||
"""
|
||
assert _luhn_check_digit("0") == 0
|
||
assert _luhn_check_digit("1") == 8
|
||
|
||
|
||
def test_luhn_check_digit_nppes_published():
|
||
"""CMS-published example: body 123456789 → check digit 3.
|
||
|
||
Per https://www.cms.gov/.../NPIcheckdigit.pdf the Luhn sum of the
|
||
prefixed body ``80840123456789`` is 67, so check digit = (10-7)%10 = 3,
|
||
giving the full NPI ``1234567893``.
|
||
"""
|
||
assert _luhn_check_digit("80840123456789") == 3
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# npi_checksum
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_npi_checksum_cms_published_body():
|
||
"""For NPI body 123456789 the check digit is 3 → NPI 1234567893.
|
||
|
||
Confirmed against the CMS-published NPI Luhn example.
|
||
"""
|
||
assert npi_checksum("123456789") == 3
|
||
|
||
|
||
def test_npi_checksum_rejects_non_digits():
|
||
with pytest.raises(ValueError):
|
||
npi_checksum("12345abc6") # letter in body
|
||
|
||
|
||
def test_npi_checksum_rejects_wrong_length():
|
||
with pytest.raises(ValueError):
|
||
npi_checksum("12345") # only 5 digits
|
||
with pytest.raises(ValueError):
|
||
npi_checksum("1234567890") # 10 digits — includes the check digit
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# is_valid_npi
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_is_valid_npi_nppes_sample_is_true():
|
||
"""The CMS-published example NPI 1234567893 must validate."""
|
||
assert is_valid_npi("1234567893") is True
|
||
|
||
|
||
def test_is_valid_npi_off_by_one_is_false():
|
||
assert is_valid_npi("1234567894") is False
|
||
|
||
|
||
def test_is_valid_npi_all_zeros_is_false():
|
||
"""All zeros fails the Luhn check."""
|
||
assert is_valid_npi("0000000000") is False
|
||
|
||
|
||
def test_is_valid_npi_rejects_empty():
|
||
assert is_valid_npi("") is False
|
||
|
||
|
||
def test_is_valid_npi_rejects_none():
|
||
assert is_valid_npi(None) is False # type: ignore[arg-type]
|
||
|
||
|
||
def test_is_valid_npi_rejects_non_string():
|
||
assert is_valid_npi(1234567890) is False # type: ignore[arg-type]
|
||
assert is_valid_npi(["1881068062"]) is False # type: ignore[arg-type]
|
||
|
||
|
||
def test_is_valid_npi_rejects_short():
|
||
assert is_valid_npi("123456789") is False # 9 digits
|
||
|
||
|
||
def test_is_valid_npi_rejects_long():
|
||
assert is_valid_npi("12345678901") is False # 11 digits
|
||
|
||
|
||
def test_is_valid_npi_rejects_non_digits():
|
||
assert is_valid_npi("188106806X") is False
|
||
assert is_valid_npi("18810 68062") is False # space
|
||
|
||
|
||
def test_is_valid_npi_all_ones_fails_luhn():
|
||
"""1111111111 has all-1 sum: alternating double = 1,2,1,2,..., 1+2=3 then
|
||
collapse. Total for 10 digits body (body=9 of all 1's, sum doubled):
|
||
Position from right (i=0..8): 1, 1, 1, 1, 1, 1, 1, 1, 1
|
||
i even (not doubled): 1, 1, 1, 1, 1 → 5
|
||
i odd (doubled): 1*2=2, 1*2=2, 1*2=2, 1*2=2 → 8
|
||
Total body = 13. Body alone has check digit (10 - 13 % 10) % 10 = 7.
|
||
So full NPI 1111111111's body (9 ones) check = 7, and 1 != 7 → invalid.
|
||
"""
|
||
assert is_valid_npi("1111111111") is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# is_valid_tax_id / normalize_tax_id
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_is_valid_tax_id_touch_of_care_true():
|
||
"""The operator's reference EIN — Touch of Care Family Practice."""
|
||
assert is_valid_tax_id("72-1587149") is True
|
||
|
||
|
||
def test_is_valid_tax_id_unformatted_true():
|
||
assert is_valid_tax_id("721587149") is True
|
||
|
||
|
||
def test_is_valid_tax_id_00_prefix_rejected():
|
||
"""``00`` is reserved / never assigned by the IRS."""
|
||
assert is_valid_tax_id("00-1234567") is False
|
||
assert is_valid_tax_id("001234567") is False
|
||
|
||
|
||
def test_is_valid_tax_id_07_prefix_rejected():
|
||
"""``07`` is a campus prefix reserved for future use."""
|
||
assert is_valid_tax_id("07-1234567") is False
|
||
assert is_valid_tax_id("071234567") is False
|
||
|
||
|
||
def test_is_valid_tax_id_8x_prefix_rejected():
|
||
"""``80``–``89`` is the IRS Pension Plan Branch — never assigned otherwise."""
|
||
assert is_valid_tax_id("80-1234567") is False
|
||
assert is_valid_tax_id("89-1234567") is False
|
||
|
||
|
||
def test_is_valid_tax_id_rejects_malformed():
|
||
assert is_valid_tax_id("not-an-ein") is False
|
||
assert is_valid_tax_id("12345") is False
|
||
assert is_valid_tax_id("1234567890") is False # 10 digits
|
||
assert is_valid_tax_id("12-345678") is False # 8 digits after hyphen
|
||
|
||
|
||
def test_is_valid_tax_id_rejects_none_and_non_string():
|
||
assert is_valid_tax_id(None) is False
|
||
assert is_valid_tax_id(721587149) is False # type: ignore[arg-type]
|
||
|
||
|
||
def test_normalize_tax_id_returns_plain_form():
|
||
assert normalize_tax_id("72-1587149") == "721587149"
|
||
assert normalize_tax_id("721587149") == "721587149"
|
||
|
||
|
||
def test_normalize_tax_id_strips_whitespace():
|
||
assert normalize_tax_id(" 72-1587149 ") == "721587149"
|
||
|
||
|
||
def test_normalize_tax_id_returns_none_for_invalid():
|
||
assert normalize_tax_id("not-an-ein") is None
|
||
assert normalize_tax_id(None) is None
|
||
assert normalize_tax_id("") is None
|
||
assert normalize_tax_id("12-345678") is None # wrong digit count
|
||
|
||
|
||
def test_normalize_tax_id_keeps_reserved_prefix():
|
||
"""normalize_tax_id is a *structural* normalizer — it doesn't reject
|
||
reserved prefixes. That's ``is_valid_tax_id``'s job. Operators who
|
||
want to store 00-prefixed EINs as placeholders still get a clean
|
||
9-digit string."""
|
||
assert normalize_tax_id("00-1234567") == "001234567" |