534 lines
18 KiB
Python
534 lines
18 KiB
Python
"""SP39: tests for the `cyclone resubmissions status` CLI."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from cyclone import db as cycl_db
|
|
from cyclone.cli import resubmissions_status_cmd
|
|
from cyclone.db import Resubmission
|
|
|
|
|
|
def _seed_one(claim_id: str = "claim-1", batch_id: str = "batch-1",
|
|
icn: str = "000000001") -> None:
|
|
cycl_db.init_db()
|
|
with cycl_db.SessionLocal()() as s:
|
|
s.add(Resubmission(
|
|
claim_id=claim_id,
|
|
batch_id=batch_id,
|
|
resubmitted_at=datetime.now(timezone.utc),
|
|
source_corrected_path=f"/tmp/{claim_id}.x12",
|
|
interchange_control_number=icn,
|
|
group_control_number="1",
|
|
))
|
|
s.commit()
|
|
|
|
|
|
def _cleanup(batch_id: str) -> None:
|
|
with cycl_db.SessionLocal()() as s:
|
|
s.query(Resubmission).filter(Resubmission.batch_id == batch_id).delete()
|
|
s.commit()
|
|
|
|
|
|
def test_no_resubmissions_prints_message_and_exits_0():
|
|
cycl_db.init_db()
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
resubmissions_status_cmd,
|
|
["--batch-id", "no-such-batch-zzz"],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "no resubmissions recorded" in result.output
|
|
|
|
|
|
def test_one_resubmission_prints_status_row():
|
|
try:
|
|
_seed_one(claim_id="claim-A", batch_id="batch-A")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
resubmissions_status_cmd,
|
|
["--batch-id", "batch-A"],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "claim-A" in result.output
|
|
assert "pending_999" in result.output # no inbound ack yet
|
|
assert "batch-A" in result.output
|
|
finally:
|
|
_cleanup("batch-A")
|
|
|
|
|
|
def test_limit_truncates_output():
|
|
try:
|
|
for i in range(5):
|
|
_seed_one(
|
|
claim_id=f"claim-L{i}", batch_id="batch-L",
|
|
icn=f"00000000{i}",
|
|
)
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
resubmissions_status_cmd,
|
|
["--batch-id", "batch-L", "--limit", "2"],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "more" in result.output
|
|
# Only 2 rows printed in the table (plus 1 "(N more)" line).
|
|
body_lines = [
|
|
ln for ln in result.output.splitlines()
|
|
if ln.startswith("claim-L")
|
|
]
|
|
assert len(body_lines) == 2
|
|
finally:
|
|
_cleanup("batch-L")
|
|
|
|
|
|
def test_status_reflects_paid_when_remittance_links():
|
|
"""When a remittance.claim_id links to the resubmitted claim,
|
|
the payment column shows 'paid'."""
|
|
try:
|
|
from cyclone.db import Remittance
|
|
from decimal import Decimal
|
|
_seed_one(claim_id="claim-P", batch_id="batch-P")
|
|
with cycl_db.SessionLocal()() as s:
|
|
s.add(Remittance(
|
|
id="remit-P-1",
|
|
batch_id="batch-P",
|
|
payer_claim_control_number="claim-P",
|
|
claim_id="claim-P",
|
|
status_code="1",
|
|
status_label="Processed as Primary",
|
|
total_charge=Decimal("100.00"),
|
|
total_paid=Decimal("80.00"),
|
|
adjustment_amount=Decimal("0"),
|
|
claim_level_adjustment_amount=Decimal("0"),
|
|
received_at=datetime.now(timezone.utc),
|
|
))
|
|
s.commit()
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
resubmissions_status_cmd,
|
|
["--batch-id", "batch-P"],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "claim-P" in result.output
|
|
assert "paid" in result.output
|
|
finally:
|
|
from cyclone.db import Remittance as _R
|
|
with cycl_db.SessionLocal()() as s:
|
|
s.query(_R).filter(_R.id == "remit-P-1").delete()
|
|
s.commit()
|
|
_cleanup("batch-P")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SP39: `cyclone resubmit-rejected-claims` instruments Resubmission rows.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _seed_clearhouse_stub_false(tmp_path) -> None:
|
|
"""Seed the clearhouse singleton and flip ``sftp_block.stub=False``
|
|
with a non-routable host so the CLI proceeds past the guard and
|
|
the paramiko stub below intercepts the connection."""
|
|
import json
|
|
from cyclone import db as db_mod
|
|
from cyclone.db import ClearhouseORM
|
|
from cyclone.store import store as cycl_store
|
|
cycl_store.ensure_clearhouse_seeded()
|
|
with db_mod.SessionLocal()() as s:
|
|
ch = s.get(ClearhouseORM, 1)
|
|
assert ch is not None, "ensure_clearhouse_seeded should have created it"
|
|
block = json.loads(json.dumps(ch.sftp_block_json))
|
|
block["stub"] = False
|
|
block["host"] = "stub.invalid"
|
|
ch.sftp_block_json = block
|
|
s.commit()
|
|
|
|
|
|
def test_resubmit_rejected_claims_inserts_resubmission_row(tmp_path, monkeypatch):
|
|
"""SP39: after a successful upload, one Resubmission row per claim
|
|
is inserted with the parent dir's batch_id and the parsed envelope
|
|
control numbers."""
|
|
import sys
|
|
import types
|
|
from cyclone.cli import resubmit_rejected_claims
|
|
from cyclone.parsers.parse_837 import parse as parse_837_text
|
|
from cyclone.parsers.payer import PayerConfig
|
|
from cyclone.store import store as cycl_store
|
|
|
|
_seed_clearhouse_stub_false(tmp_path)
|
|
|
|
# Build a parseable 837P file (CO_TXIX payer, single claim).
|
|
cfg = PayerConfig.co_medicaid()
|
|
sample = tmp_path / "batch-deadbeef-1-claims"
|
|
sample.mkdir()
|
|
file_path = sample / "tp1-837P-test-1of1.x12"
|
|
src_text = open("tests/fixtures/co_medicaid_837p.txt").read()
|
|
file_path.write_text(src_text)
|
|
|
|
parsed = parse_837_text(src_text, cfg)
|
|
assert parsed.claims, "fixture must produce at least one claim"
|
|
first_claim_id = parsed.claims[0].claim_id
|
|
|
|
# Replace paramiko with a fake whose stat() raises (file not on
|
|
# remote yet, so the CLI takes the upload branch).
|
|
fake_paramiko = types.ModuleType("paramiko")
|
|
|
|
class _FakeSFTP:
|
|
def stat(self, path):
|
|
raise IOError("not found")
|
|
def put(self, local, remote):
|
|
return None
|
|
|
|
class _FakeSSH:
|
|
def open_sftp(self):
|
|
return _FakeSFTP()
|
|
def set_missing_host_key_policy(self, policy):
|
|
return None
|
|
def connect(self, *a, **kw):
|
|
return None
|
|
def close(self):
|
|
return None
|
|
|
|
fake_paramiko.SSHClient = lambda: _FakeSSH()
|
|
fake_paramiko.AutoAddPolicy = lambda: None
|
|
monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko)
|
|
|
|
captured: list[dict] = []
|
|
monkeypatch.setattr(
|
|
cycl_store, "record_resubmission",
|
|
lambda **kw: captured.append(kw) or True,
|
|
)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(resubmit_rejected_claims, [
|
|
"--ingest-dir", str(tmp_path),
|
|
"--limit", "1",
|
|
"--no-validate",
|
|
"--skip-edifabric-validate",
|
|
])
|
|
assert result.exit_code == 0, (
|
|
f"CLI exited {result.exit_code}, output={result.output!r}, "
|
|
f"exception={result.exception!r}"
|
|
)
|
|
assert len(captured) >= 1, (
|
|
f"expected record_resubmission to be called, got {captured!r}"
|
|
)
|
|
call = captured[0]
|
|
assert call["claim_id"] == first_claim_id, call
|
|
assert call["batch_id"] == "deadbeef", call
|
|
assert "deadbeef-1-claims" in call["source_corrected_path"], call
|
|
assert call["interchange_control_number"], call
|
|
assert call["group_control_number"], call
|
|
|
|
|
|
def test_resubmit_idempotency_does_not_create_duplicate_rows(tmp_path, monkeypatch):
|
|
"""A second run against the same file (now on the SFTP) does NOT
|
|
insert a second Resubmission row — only the original upload branch
|
|
writes, matching the audit-log behavior."""
|
|
from cyclone.cli import resubmit_rejected_claims
|
|
from cyclone.store import store as cycl_store
|
|
import sys
|
|
import types
|
|
|
|
_seed_clearhouse_stub_false(tmp_path)
|
|
|
|
# Set up: one corrected file.
|
|
sample = tmp_path / "batch-cafebabe-1-claims"
|
|
sample.mkdir()
|
|
file_path = sample / "tp1-837P-test-1of1.x12"
|
|
src_text = open("tests/fixtures/co_medicaid_837p.txt").read()
|
|
file_path.write_text(src_text)
|
|
on_remote_size = len(src_text.encode())
|
|
|
|
# Build a paramiko fake where stat() returns matching size so the
|
|
# CLI takes the skip branch. The CLI only reads ``st_size`` off the
|
|
# return value, so a simple duck-typed object suffices.
|
|
fake_paramiko = types.ModuleType("paramiko")
|
|
|
|
class _FakeAttr:
|
|
def __init__(self, size: int) -> None:
|
|
self.st_size = size
|
|
|
|
class _FakeSFTPAlready:
|
|
def stat(self, path):
|
|
return _FakeAttr(on_remote_size)
|
|
|
|
def put(self, local, remote):
|
|
raise AssertionError("should not put when file already on remote")
|
|
|
|
class _FakeSSHAlready:
|
|
def open_sftp(self):
|
|
return _FakeSFTPAlready()
|
|
def set_missing_host_key_policy(self, policy):
|
|
return None
|
|
def connect(self, *a, **kw):
|
|
return None
|
|
def close(self):
|
|
return None
|
|
|
|
fake_paramiko.SSHClient = lambda: _FakeSSHAlready()
|
|
fake_paramiko.AutoAddPolicy = lambda: None
|
|
monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko)
|
|
|
|
captured: list[dict] = []
|
|
monkeypatch.setattr(
|
|
cycl_store, "record_resubmission",
|
|
lambda **kw: captured.append(kw) or True,
|
|
)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(resubmit_rejected_claims, [
|
|
"--ingest-dir", str(tmp_path),
|
|
"--limit", "1",
|
|
"--no-validate",
|
|
"--skip-edifabric-validate",
|
|
])
|
|
# On the skip path, record_resubmission should NOT be called.
|
|
assert captured == [], f"expected no record_resubmission on skip, got {captured!r}"
|
|
assert "skipped" in result.output, result.output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SP40: `cyclone resubmit-rejected-claims` Edifabric pre-upload gate.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_TEST_KEY = "test-edifabric-key-0123456789abcdef"
|
|
|
|
|
|
def _install_edifabric_mock(handler):
|
|
"""Install a mocked Edifabric transport + get_secret stub for the
|
|
duration of a single test. Returned teardown callable can be wired
|
|
to monkeypatch via the autouse fixture pattern below."""
|
|
import sys
|
|
import httpx
|
|
from cyclone import edifabric
|
|
import cyclone.secrets as _secrets_mod
|
|
|
|
real_get_secret = _secrets_mod.get_secret
|
|
|
|
def _fake_get_secret(name):
|
|
if name == "edifabric.api_key":
|
|
return _TEST_KEY
|
|
return real_get_secret(name)
|
|
|
|
_secrets_mod.get_secret = _fake_get_secret
|
|
transport = httpx.MockTransport(handler)
|
|
edifabric.set_transport_factory(
|
|
lambda: httpx.Client(transport=transport, timeout=10.0)
|
|
)
|
|
|
|
def _teardown():
|
|
_secrets_mod.get_secret = real_get_secret
|
|
edifabric._reset_transport_factory()
|
|
|
|
return _teardown
|
|
|
|
|
|
def _two_files_setup(tmp_path):
|
|
"""Stage two .x12 files in distinct batch dirs so the test can
|
|
observe one file's gate failure not blocking the other's upload."""
|
|
src_text = open("tests/fixtures/co_medicaid_837p.txt").read()
|
|
a = tmp_path / "batch-aaaaaaaa-1-claims"
|
|
a.mkdir()
|
|
(a / "tp1-aaaaaaaa-1of1.x12").write_text(src_text)
|
|
b = tmp_path / "batch-bbbbbbbb-1-claims"
|
|
b.mkdir()
|
|
(b / "tp1-bbbbbbbb-1of1.x12").write_text(src_text)
|
|
return src_text
|
|
|
|
|
|
def test_resubmit_edifabric_gate_aborts_bad_file_continues_batch(
|
|
tmp_path, monkeypatch,
|
|
):
|
|
"""SP40: when Edifabric returns Status='error' for one file, that
|
|
file is recorded as ``validate_failed`` (not uploaded, no
|
|
Resubmission row), but the batch continues with the next file."""
|
|
import sys
|
|
import types
|
|
from cyclone.cli import resubmit_rejected_claims
|
|
|
|
_seed_clearhouse_stub_false(tmp_path)
|
|
_two_files_setup(tmp_path)
|
|
|
|
seen: list[str] = []
|
|
x12 = {
|
|
"SegmentDelimiter": "~",
|
|
"DataElementDelimiter": "*",
|
|
"ISA": {"InterchangeControlNumber_13": "000000001"},
|
|
"Groups": [],
|
|
"IEATrailers": [],
|
|
}
|
|
|
|
def edifabric_handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith("/read"):
|
|
return httpx.Response(200, json=[x12])
|
|
if request.url.path.endswith("/validate"):
|
|
# Fail the FIRST validate call; succeed on the second.
|
|
seen.append("validate")
|
|
is_bad = len(seen) == 1
|
|
return httpx.Response(200, json={
|
|
"Status": "error" if is_bad else "success",
|
|
"Details": (
|
|
[
|
|
{"SegmentId": "PER",
|
|
"Message": "PER-04 is required",
|
|
"Status": "error"},
|
|
]
|
|
if is_bad else []
|
|
),
|
|
"LastIndex": 5 if is_bad else 46,
|
|
})
|
|
raise AssertionError(f"unexpected path: {request.url.path}")
|
|
|
|
import httpx
|
|
teardown = _install_edifabric_mock(edifabric_handler)
|
|
try:
|
|
# Fake paramiko so SFTP doesn't actually fire.
|
|
fake_paramiko = types.ModuleType("paramiko")
|
|
|
|
class _FakeAttr:
|
|
def __init__(self, size: int) -> None:
|
|
self.st_size = size
|
|
|
|
class _FakeSFTP:
|
|
def __init__(self):
|
|
self.put_calls: list[str] = []
|
|
|
|
def stat(self, path):
|
|
raise IOError("not found")
|
|
|
|
def put(self, local, remote):
|
|
self.put_calls.append(remote)
|
|
|
|
fake_sftp = _FakeSFTP()
|
|
|
|
class _FakeSSH:
|
|
def open_sftp(self):
|
|
return fake_sftp
|
|
|
|
def set_missing_host_key_policy(self, policy):
|
|
return None
|
|
|
|
def connect(self, *a, **kw):
|
|
return None
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
fake_paramiko.SSHClient = lambda: _FakeSSH()
|
|
fake_paramiko.AutoAddPolicy = lambda: None
|
|
monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(resubmit_rejected_claims, [
|
|
"--ingest-dir", str(tmp_path),
|
|
"--no-validate",
|
|
# default: gate ENABLED
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# First file: Edifabric gate aborted it (no SFTP put for it).
|
|
# Second file: passed the gate, got uploaded via the SFTP put.
|
|
assert len(fake_sftp.put_calls) == 1, fake_sftp.put_calls
|
|
assert "bbbbbbbb" in fake_sftp.put_calls[0], fake_sftp.put_calls[0]
|
|
|
|
# Output surfaces the failure list.
|
|
assert "EDIFABRIC VALIDATION FAIL" in result.output, result.output
|
|
assert "1 file(s) blocked by Edifabric gate" in result.output, result.output
|
|
assert "aaaaaaa" in result.output, result.output
|
|
assert "PER-04 is required" in result.output, result.output
|
|
|
|
# DONE summary shows both counters.
|
|
assert "validate_failed=1" in result.output, result.output
|
|
assert "uploaded=1" in result.output, result.output
|
|
finally:
|
|
teardown()
|
|
|
|
|
|
def test_resubmit_edifabric_skip_flag_bypasses_gate(tmp_path, monkeypatch):
|
|
"""SP40: ``--skip-edifabric-validate`` bypasses the gate entirely
|
|
so the same Edifabric-failing file uploads via SFTP. Used for
|
|
dev / hot-fix scenarios."""
|
|
import sys
|
|
import types
|
|
from cyclone.cli import resubmit_rejected_claims
|
|
|
|
_seed_clearhouse_stub_false(tmp_path)
|
|
_two_files_setup(tmp_path)
|
|
|
|
import httpx
|
|
x12 = {
|
|
"SegmentDelimiter": "~",
|
|
"DataElementDelimiter": "*",
|
|
"ISA": {"InterchangeControlNumber_13": "000000001"},
|
|
"Groups": [],
|
|
"IEATrailers": [],
|
|
}
|
|
|
|
validate_calls: list[str] = []
|
|
|
|
def edifabric_handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith("/read"):
|
|
return httpx.Response(200, json=[x12])
|
|
if request.url.path.endswith("/validate"):
|
|
validate_calls.append("hit")
|
|
return httpx.Response(200, json={
|
|
"Status": "error",
|
|
"Details": [{"SegmentId": "PER",
|
|
"Message": "PER-04 is required",
|
|
"Status": "error"}],
|
|
"LastIndex": 5,
|
|
})
|
|
raise AssertionError(f"unexpected path: {request.url.path}")
|
|
|
|
teardown = _install_edifabric_mock(edifabric_handler)
|
|
try:
|
|
fake_paramiko = types.ModuleType("paramiko")
|
|
|
|
class _FakeSFTP:
|
|
def __init__(self):
|
|
self.put_calls: list[str] = []
|
|
|
|
def stat(self, path):
|
|
raise IOError("not found")
|
|
|
|
def put(self, local, remote):
|
|
self.put_calls.append(remote)
|
|
|
|
fake_sftp = _FakeSFTP()
|
|
|
|
class _FakeSSH:
|
|
def open_sftp(self):
|
|
return fake_sftp
|
|
|
|
def set_missing_host_key_policy(self, policy):
|
|
return None
|
|
|
|
def connect(self, *a, **kw):
|
|
return None
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
fake_paramiko.SSHClient = lambda: _FakeSSH()
|
|
fake_paramiko.AutoAddPolicy = lambda: None
|
|
monkeypatch.setitem(sys.modules, "paramiko", fake_paramiko)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(resubmit_rejected_claims, [
|
|
"--ingest-dir", str(tmp_path),
|
|
"--no-validate",
|
|
"--skip-edifabric-validate",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Both files uploaded; the gate never fired.
|
|
assert len(fake_sftp.put_calls) == 2, fake_sftp.put_calls
|
|
assert validate_calls == [], (
|
|
"validate must not be called when --skip-edifabric-validate is set"
|
|
)
|
|
assert "validate_failed=0" in result.output, result.output
|
|
assert "EDIFABRIC VALIDATION FAIL" not in result.output, result.output
|
|
finally:
|
|
teardown()
|