feat(sp32): add backfill-rendering-npi CLI subcommand
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user