Files
cyclone/backend/tests/test_ack_orphans_cli.py
T
Nora 9ef749c783 feat(sp38): 'cyclone ack-orphans {status,reconcile}' CLI subcommands
Implements tasks 4-5 of
docs/superpowers/plans/2026-07-07-cyclone-orphan-ack-housekeeping.md.

Adds a new 'ack-orphans' group under the main click CLI with two
subcommands:

  cyclone ack-orphans status
    Prints a table of orphan 999 acks:
      ST02             ACK COUNT   HAS BATCH
      --------------- ----------   ---------
      991102989              419   no
      991102988              226   no
      ...
      TOTAL                  804
    Empty-DB case prints 'no orphans' instead of a blank table.

  cyclone ack-orphans reconcile [--dry-run]
    One-shot synthetic-batch seeder. --dry-run prints the plan
    without writing. Real call inserts one synthetic batches row
    per orphan ST02 lacking one, marked with the
    '<synthetic:orphan-reconcile>' sentinel so they're trivially
    distinguishable in queries.

Exit codes per cyclone-cli convention: 0 = success, 1 = DB error.
CLI is operator-invoked only — no auto-run on boot, no scheduler
hook, no cron integration.

7 new tests (test_ack_orphans_cli.py) use click.testing.CliRunner
(matching the SP37-followup #5 pattern that replaced subprocess.run
with the in-process runner). Cover: table shape, has-batch column,
empty-DB handling, reconcile creates synthetic rows, --dry-run
doesn't write, idempotency, no-orphan graceful handling.

Live-verified against ~/.local/share/cyclone/cyclone.db: status
prints the 4 distinct ST02s (419/226/106/53 = 804 total). Reconcile
inserts 4 synthetic batches. Subsequent status shows all 4 marked
has_batch=yes.
2026-07-07 12:53:51 -06:00

210 lines
7.2 KiB
Python

"""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 == "<synthetic:orphan-reconcile>")
.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 == "<synthetic:orphan-reconcile>")
.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 == "<synthetic:orphan-reconcile>")
.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