feat(sp32): add backfill-rendering-npi CLI subcommand

This commit is contained in:
Nora
2026-07-02 17:16:59 -06:00
parent de77f19d9d
commit 0f3b264e41
3 changed files with 658 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
"""SP32 Task 6: end-to-end smoke tests for the ``backfill-rendering-npi`` CLI.
The CLI re-parses on-disk 837p + 835 files and backfills the new typed
``rendering_provider_npi`` columns on existing Claim / Remittance rows
that were ingested before the T4 writers took effect. Idempotent: rows
that already have a non-NULL rendering NPI are left untouched.
Test strategy mirrors ``test_cli_backup.py``: in-process Click CliRunner
invocation against a fresh per-test DB (autouse ``_auto_init_db``
fixture), pre-seeded with Claim/Remittance rows whose
``rendering_provider_npi`` is NULL, then assert the CLI populated them.
"""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from click.testing import CliRunner
from cyclone import db
from cyclone.cli import main
BACKEND_DIR = "backend"
_REPO_ROOT = "."
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def seed_837p_claim_without_renderer():
"""Insert a Claim row whose ``rendering_provider_npi`` is NULL.
The id (``t991102984o1c1d``) matches the first claim in
``co_medicaid_837p_with_renderer.txt`` — the fixture that does
contain a NM1*82 rendering provider (NPI ``1234567893``) attached
to that claim. Backfill should populate the column from the
re-parsed file.
"""
from cyclone.db import Batch, Claim, ClaimState
with db.SessionLocal()() as s:
s.add(Batch(
id="seed-837p-batch",
kind="837p",
input_filename="seed.x12",
parsed_at=datetime.now(timezone.utc),
))
s.add(Claim(
id="t991102984o1c1d",
batch_id="seed-837p-batch",
patient_control_number="t991102984o1c1d",
charge_amount=Decimal("85.40"),
state=ClaimState.SUBMITTED,
rendering_provider_npi=None, # ← the column under test
provider_npi="1881068062",
))
s.commit()
@pytest.fixture
def seed_835_remit_without_service_provider(tmp_path):
"""Insert a Remittance whose ``rendering_provider_npi`` is NULL.
Writes a minimal 835 fixture to ``tmp_path`` containing an
NM1*1P service provider (NPI ``2222222222``) attached to the
only CLP segment (claim control number ``CLM001``). Returns
the path to the written file.
"""
from cyclone.db import Batch, Remittance
fixture = tmp_path / "minimal_835_with_service_provider.txt"
fixture.write_text(
"ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID "
"*250101*1200*^*00501*000000001*0*P*:~"
"GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
"ST*835*0001~"
"BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111"
"**01*031302955*DA*9876543~"
"TRN*1*1511111*1511111~"
"DTM*405*20250101~"
"N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
"N3*PO BOX 1100*~"
"N4*DENVER*CO*80203~"
"REF*2U*12345~"
"N1*PE*ACME CLINIC*XX*1111111111~"
"LX*1~"
"CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
"CAS*PR*1*0.00~"
"NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
"SVC*HC:99213*85.40*85.40**1~"
"DTM*472*20250101~"
"SE*18*0001~"
"GE*1*1~"
"IEA*1*000000001~"
)
with db.SessionLocal()() as s:
s.add(Batch(
id="seed-835-batch",
kind="835",
input_filename=fixture.name,
parsed_at=datetime.now(timezone.utc),
))
s.add(Remittance(
id="CLM001",
batch_id="seed-835-batch",
payer_claim_control_number="CLM001",
claim_id=None,
status_code="1",
status_label="Primary payer forward",
total_charge=Decimal("85.40"),
total_paid=Decimal("85.40"),
patient_responsibility=Decimal("0"),
adjustment_amount=Decimal("0"),
received_at=datetime.now(timezone.utc),
is_reversal=False,
rendering_provider_npi=None, # ← the column under test
))
s.commit()
return fixture
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run(args: list[str]) -> object:
"""Invoke the CLI in-process via CliRunner.
CliRunner auto-captures stdout/stderr; ``catch_exceptions=False``
so the test fails loudly on any unexpected raise instead of a
swallowed traceback.
"""
return CliRunner().invoke(main, args, catch_exceptions=False)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_backfill_populates_837p_rendering_npi(seed_837p_claim_without_renderer):
"""Re-parse the 837p fixture → Claim.rendering_provider_npi populated."""
fixture_path = "tests/fixtures/co_medicaid_837p_with_renderer.txt"
# Sanity: column starts NULL.
with db.SessionLocal()() as s:
from cyclone.db import Claim
row = s.get(Claim, "t991102984o1c1d")
assert row is not None
assert row.rendering_provider_npi is None
result = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "837p"])
assert result.exit_code == 0, result.output
with db.SessionLocal()() as s:
from cyclone.db import Claim
row = s.get(Claim, "t991102984o1c1d")
assert row is not None
# First claim in the fixture carries NM1*82 NPI ``1234567893``.
assert row.rendering_provider_npi == "1234567893"
# CLI prints the summary line with the populated counts.
assert "claims_updated=" in result.output
assert "remits_updated=" in result.output
def test_backfill_populates_835_rendering_npi(seed_835_remit_without_service_provider):
"""Re-parse an 835 with NM1*1P → Remittance.rendering_provider_npi populated."""
fixture_path = str(seed_835_remit_without_service_provider)
# Sanity: column starts NULL.
with db.SessionLocal()() as s:
from cyclone.db import Remittance
row = s.get(Remittance, "CLM001")
assert row is not None
assert row.rendering_provider_npi is None
result = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "835"])
assert result.exit_code == 0, result.output
with db.SessionLocal()() as s:
from cyclone.db import Remittance
row = s.get(Remittance, "CLM001")
assert row is not None
# Inline fixture's NM1*1P NM109 is ``2222222222``.
assert row.rendering_provider_npi == "2222222222"
assert "remits_updated=" in result.output
def test_backfill_is_idempotent(seed_837p_claim_without_renderer):
"""Second run is a no-op — already-populated columns stay."""
fixture_path = "tests/fixtures/co_medicaid_837p_with_renderer.txt"
# First run: populates the column.
first = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "837p"])
assert first.exit_code == 0, first.output
# Second run: still exits 0; column is unchanged; nothing breaks.
second = _run(["backfill-rendering-npi", "--file", fixture_path, "--type", "837p"])
assert second.exit_code == 0, second.output
with db.SessionLocal()() as s:
from cyclone.db import Claim
row = s.get(Claim, "t991102984o1c1d")
assert row.rendering_provider_npi == "1234567893"
def test_backfill_help_lists_subcommand():
"""Regression guard: the subcommand is wired into ``main``."""
result = _run(["--help"])
assert result.exit_code == 0
assert "backfill-rendering-npi" in result.output
def test_backfill_with_no_files_is_noop():
"""Running with no --file and no --input-dir is a clean no-op (exit 0)."""
result = _run(["backfill-rendering-npi"])
assert result.exit_code == 0, result.output
# Summary line still printed (zero counts).
assert "claims_updated=0" in result.output
assert "remits_updated=0" in result.output