"""SP38: CLI tests for ``cyclone ack-orphans {status,reconcile}``. Uses ``click.testing.CliRunner`` (matching the SP37-followup #5 pattern that replaced ``subprocess.run`` with the in-process runner). The CLI commands are thin wrappers over the store helpers tested in ``test_ack_orphan_summary.py`` — these tests pin the shell-facing shape (table format, exit codes, --dry-run flag). The autouse ``_auto_init_db`` fixture in ``conftest.py`` provides a fresh ``tmp_path/test.db`` for every test; the CLI commands pick it up via the ``CYCLONE_DB_URL`` env var the fixture sets. """ from __future__ import annotations import json from datetime import datetime, timezone import pytest from click.testing import CliRunner from cyclone import db from cyclone.cli import main from cyclone.db import Ack, Batch def _now(): return datetime.now(timezone.utc) def _ingest_orphan(s, st02: str) -> int: """Insert a 999 ack row whose set_responses[0].set_control_number == st02. Returns the new ack id. The 999 is an orphan (no claim_acks row) so it surfaces in the summary. """ raw = { "envelope": {"sender_id": "S", "receiver_id": "R", "control_number": "000000099", "transaction_date": "2024-01-01", "implementation_guide": "005010X231A1"}, "functional_group_acks": [], "set_responses": [ {"set_control_number": st02, "transaction_set_identifier": "837", "ak2": {"functional_id_code": "837"}, "segment_errors": [], "set_accept_reject": {"code": "A"}} ], "summary": {"accepted_count": 1, "rejected_count": 0}, } ack = Ack( source_batch_id=f"999-{st02}-cli-test", accepted_count=1, rejected_count=0, received_count=1, ack_code="A", parsed_at=_now(), raw_json=json.dumps(raw), ) s.add(ack) s.flush() return ack.id # ---- cyclone ack-orphans status ------------------------------------------ # def test_status_prints_table_with_orphans(): """``cyclone ack-orphans status`` prints a table with ST02 + ack count + has-batch flag, plus a TOTAL line at the bottom. Mirrors the spec's intent: operators want a one-line-per-orphan view that ranks the heaviest backlog first. """ with db.SessionLocal()() as s: _ingest_orphan(s, "991102989") _ingest_orphan(s, "991102988") s.commit() runner = CliRunner() result = runner.invoke(main, ["ack-orphans", "status"]) assert result.exit_code == 0, result.output # Table header + two data rows + total line. assert "ST02" in result.output assert "ACK COUNT" in result.output assert "991102989" in result.output assert "991102988" in result.output assert "TOTAL" in result.output def test_status_exits_zero_on_empty_db(): """With no orphans, status exits 0 and prints a 'no orphans' note.""" runner = CliRunner() result = runner.invoke(main, ["ack-orphans", "status"]) assert result.exit_code == 0, result.output # The empty case should be informative, not silent. assert "no orphans" in result.output.lower() or "0" in result.output def test_status_table_includes_has_batch_column(): """When a ST02 already has a batches row, the table marks it with ``yes`` (or similar) so operators can see which orphans are unbacked vs already-covered. """ with db.SessionLocal()() as s: # Orphan with NO batch. _ingest_orphan(s, "991102988") # Orphan WITH a pre-existing batch row. import uuid bid = uuid.uuid4().hex s.add(Batch( id=bid, kind="837p", input_filename="test.837p", transaction_set_control_number="991102977", parsed_at=_now(), )) _ingest_orphan(s, "991102977") s.commit() runner = CliRunner() result = runner.invoke(main, ["ack-orphans", "status"]) assert result.exit_code == 0, result.output # Look for "yes" / "no" markers in the table. lines = result.output.splitlines() has_yes = any("yes" in ln.lower() for ln in lines) has_no = any("no" in ln.lower() for ln in lines) assert has_yes and has_no, ( f"Expected table to mark 'yes' for ST02s with batches and " f"'no' for ST02s without. Got:\n{result.output}" ) # ---- cyclone ack-orphans reconcile --------------------------------------- # def test_reconcile_creates_synthetic_batches(): """``cyclone ack-orphans reconcile`` inserts a synthetic batch row for each orphan ST02 without one. Confirms the count + the sentinel filename. """ with db.SessionLocal()() as s: _ingest_orphan(s, "991102989") _ingest_orphan(s, "991102988") s.commit() runner = CliRunner() result = runner.invoke(main, ["ack-orphans", "reconcile"]) assert result.exit_code == 0, result.output # The CLI should print the created/skipped counts. assert "Created" in result.output or "created" in result.output assert "Skipped" in result.output or "skipped" in result.output with db.SessionLocal()() as s: synthetic = ( s.query(Batch) .filter(Batch.input_filename == "") .all() ) assert len(synthetic) == 2 st02s = {r.transaction_set_control_number for r in synthetic} assert st02s == {"991102989", "991102988"} def test_reconcile_dry_run_does_not_write(): """``--dry-run`` returns the plan shape but does NOT insert rows. Operators use this to preview before committing. """ with db.SessionLocal()() as s: _ingest_orphan(s, "991102989") s.commit() runner = CliRunner() result = runner.invoke(main, ["ack-orphans", "reconcile", "--dry-run"]) assert result.exit_code == 0, result.output # The plan reports the would-create count. assert "1" in result.output # would-create 1 row # But no synthetic batch row was inserted. with db.SessionLocal()() as s: n = ( s.query(Batch) .filter(Batch.input_filename == "") .count() ) assert n == 0 def test_reconcile_is_idempotent(): """A second reconcile after a successful pass is a no-op: created=0, skipped=N. Tested at the CLI shell level so the user-visible output also pins this invariant. """ with db.SessionLocal()() as s: _ingest_orphan(s, "991102989") s.commit() runner = CliRunner() first = runner.invoke(main, ["ack-orphans", "reconcile"]) second = runner.invoke(main, ["ack-orphans", "reconcile"]) assert first.exit_code == 0 assert second.exit_code == 0 with db.SessionLocal()() as s: n = ( s.query(Batch) .filter(Batch.input_filename == "") .count() ) assert n == 1 # still exactly one — second call was a no-op def test_reconcile_handles_no_orphans_gracefully(): """With zero orphans, reconcile exits 0 and prints an informative message (no synthetic rows created, no error).""" runner = CliRunner() result = runner.invoke(main, ["ack-orphans", "reconcile"]) assert result.exit_code == 0, result.output assert "0" in result.output # created=0, skipped=0