Files
cyclone/backend/tests/test_npi.py
T
Tyler 1942a22629 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.
2026-06-21 10:46:10 -06:00

199 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"