"""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", ]) 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", ]) # 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