feat(sp20): NPI Luhn checksum + Tax ID (EIN) format validation

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.
This commit is contained in:
Tyler
2026-06-21 10:46:10 -06:00
parent f26bbe061a
commit 1942a22629
13 changed files with 838 additions and 3 deletions
+199
View File
@@ -0,0 +1,199 @@
"""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"