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