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
@@ -0,0 +1,69 @@
"""Tests for ``GET /api/admin/validate-provider`` (SP20).
Pure read-only endpoint — runs the local NPI Luhn + EIN format checks.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
# ---------------------------------------------------------------------------
# Both fields populated
# ---------------------------------------------------------------------------
def test_validate_provider_both_valid(client: TestClient):
resp = client.get("/api/admin/validate-provider", params={
"npi": "1234567893", # CMS-published valid NPI
"tax_id": "72-1587149", # Touch of Care EIN
})
assert resp.status_code == 200
body = resp.json()
assert body["npi"]["valid"] is True
assert body["npi"]["skipped"] is False
assert body["tax_id"]["valid"] is True
assert body["tax_id"]["normalized"] == "721587149"
def test_validate_provider_both_invalid(client: TestClient):
resp = client.get("/api/admin/validate-provider", params={
"npi": "1234567890", # format OK but Luhn fails
"tax_id": "00-1234567", # reserved prefix
})
assert resp.status_code == 200
body = resp.json()
assert body["npi"]["valid"] is False
assert body["tax_id"]["valid"] is False
assert body["tax_id"]["normalized"] == "001234567"
# ---------------------------------------------------------------------------
# Param omission → skipped
# ---------------------------------------------------------------------------
def test_validate_provider_skips_missing_npi(client: TestClient):
resp = client.get("/api/admin/validate-provider", params={"tax_id": "721587149"})
assert resp.status_code == 200
body = resp.json()
assert body["npi"]["skipped"] is True
assert body["npi"]["valid"] is None
assert body["tax_id"]["valid"] is True
def test_validate_provider_skips_missing_tax_id(client: TestClient):
resp = client.get("/api/admin/validate-provider", params={"npi": "1234567893"})
assert resp.status_code == 200
body = resp.json()
assert body["tax_id"]["skipped"] is True
assert body["tax_id"]["valid"] is None
assert body["tax_id"]["normalized"] is None
assert body["npi"]["valid"] is True