"""SP38: store-helper tests for ``find_ack_orphan_st02_summary``. The summary is the per-ST02 breakdown the ``cyclone ack-orphans`` CLI commands consume. It enumerates every distinct orphan 999 ST02 + ack count + whether a ``batches`` row already covers that ST02. Tests live here (not in ``test_apply_claim_ack_links.py``) because the helper is a sp38 surface, not an sp28 invariant. Keeping the tests in a sibling file matches the ``cyclone-tests`` convention. The autouse ``_auto_init_db`` fixture in ``conftest.py`` provides a fresh ``tmp_path/test.db`` for every test — no manual DB setup needed here. """ from __future__ import annotations import json from datetime import datetime, timezone import pytest from cyclone import db from cyclone.db import Ack, Batch, ClaimAck from cyclone.store import CycloneStore def _now(): """A single shared 'now' for deterministic test timestamps.""" return datetime.now(timezone.utc) def _ingest_999(s, st02: str, *, batch_id: str | None = None) -> 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 is inserted) so it surfaces in the summary. """ raw = { "envelope": { "sender_id": "SUBMITTERID", "receiver_id": "RECEIVERID", "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=batch_id or f"999-{st02}-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 def _seed_batch(s, *, st02: str, batch_id: str | None = None) -> str: """Insert a batches row with the given ST02. Returns the batch id.""" import uuid bid = batch_id or uuid.uuid4().hex s.add(Batch( id=bid, kind="837p", input_filename="test.837p", transaction_set_control_number=st02, parsed_at=_now(), )) s.flush() return bid def test_summary_returns_per_st02_counts(): """Two distinct orphan ST02s surface as two separate summary rows.""" with db.SessionLocal()() as s: _ingest_999(s, "991102989") _ingest_999(s, "991102989") # same ST02 twice _ingest_999(s, "991102988") s.commit() summary = CycloneStore().find_ack_orphan_st02_summary() by_st02 = {row["st02"]: row for row in summary} assert by_st02["991102989"]["ack_count"] == 2 assert by_st02["991102988"]["ack_count"] == 1 assert by_st02["991102989"]["has_batch"] is False assert by_st02["991102988"]["has_batch"] is False def test_summary_sets_has_batch_true_when_batches_row_exists(): """When a batches row has the orphan ST02, ``has_batch`` is True and ``batch_id`` matches the batches row's id.""" with db.SessionLocal()() as s: bid = _seed_batch(s, st02="991102977") _ingest_999(s, "991102977", batch_id=bid) s.commit() summary = CycloneStore().find_ack_orphan_st02_summary() assert len(summary) == 1 row = summary[0] assert row["st02"] == "991102977" assert row["ack_count"] == 1 assert row["has_batch"] is True assert row["batch_id"] == bid def test_summary_sorted_by_ack_count_descending(): """The summary is sorted so the heaviest orphans surface first in the CLI output (matches the spec's intent that operators triage the biggest backlog first).""" with db.SessionLocal()() as s: for _ in range(3): _ingest_999(s, "991102988") _ingest_999(s, "991102987") for _ in range(5): _ingest_999(s, "991102989") s.commit() summary = CycloneStore().find_ack_orphan_st02_summary() counts = [row["ack_count"] for row in summary] assert counts == sorted(counts, reverse=True) assert counts[0] == 5 # 991102989 has the most def test_summary_excludes_999s_that_have_a_claim_acks_link(): """A 999 with a claim_acks row is NOT an orphan and must not appear in the summary.""" with db.SessionLocal()() as s: linked_id = _ingest_999(s, "991102977") _ingest_999(s, "991102988") # orphan, no link # Manually insert a claim_acks link for the first 999. s.add(ClaimAck( claim_id="CLM-1", batch_id="b1", ack_id=linked_id, ack_kind="999", ak2_index=0, set_control_number="991102977", set_accept_reject_code="A", linked_at=_now(), linked_by="auto", )) s.commit() summary = CycloneStore().find_ack_orphan_st02_summary() by_st02 = {row["st02"]: row for row in summary} assert "991102977" not in by_st02 # linked → not orphan assert by_st02["991102988"]["ack_count"] == 1 def test_summary_skips_999s_with_malformed_raw_json(): """999s whose raw_json is missing set_responses or is malformed JSON are SKIPPED — they can't be reconciled, so they don't contribute to the summary.""" with db.SessionLocal()() as s: # Valid orphan _ingest_999(s, "991102988") # Malformed JSON — raw_json is not parseable s.add(Ack( source_batch_id="999-malformed", accepted_count=1, rejected_count=0, received_count=1, ack_code="A", parsed_at=_now(), raw_json="{not valid json", )) # Empty set_responses s.add(Ack( source_batch_id="999-empty", accepted_count=1, rejected_count=0, received_count=1, ack_code="A", parsed_at=_now(), raw_json=json.dumps({"envelope": {}, "set_responses": [], "summary": {}}), )) s.commit() summary = CycloneStore().find_ack_orphan_st02_summary() by_st02 = {row["st02"]: row for row in summary} assert set(by_st02.keys()) == {"991102988"} assert by_st02["991102988"]["ack_count"] == 1 def test_summary_empty_when_no_orphans(): """With zero orphans, the summary is an empty list.""" summary = CycloneStore().find_ack_orphan_st02_summary() assert summary == [] # ---- Task 3: reconcile_orphan_st02s --------------------------------------- # def test_reconcile_creates_synthetic_batches_for_missing_st02s(): """``reconcile_orphan_st02s`` inserts one synthetic batch row per orphan ST02 that doesn't already have a batches row. Each synthetic row uses the sentinel ``input_filename`` and ``kind = '837p'`` so they're trivially distinguishable in queries (the spec: grep for ```` to find every row this SP38 created). """ with db.SessionLocal()() as s: _ingest_999(s, "991102989") _ingest_999(s, "991102988") s.commit() plan = CycloneStore().reconcile_orphan_st02s(dry_run=False) assert plan["created"] == 2 assert plan["skipped"] == 0 assert len(plan["synthetic_batch_ids"]) == 2 with db.SessionLocal()() as s: rows = ( s.query(Batch) .filter(Batch.input_filename == "") .all() ) assert len(rows) == 2 assert all(r.kind == "837p" for r in rows) st02s = {r.transaction_set_control_number for r in rows} assert st02s == {"991102989", "991102988"} def test_reconcile_skips_st02s_that_already_have_a_batch(): """``reconcile`` does NOT create a synthetic row for an ST02 that already has a batches row — the operator's intent is to fill gaps, not duplicate coverage.""" with db.SessionLocal()() as s: existing = _seed_batch(s, st02="991102977") _ingest_999(s, "991102977", batch_id=existing) _ingest_999(s, "991102988") # orphan, no batch s.commit() plan = CycloneStore().reconcile_orphan_st02s(dry_run=False) assert plan["created"] == 1 assert plan["skipped"] == 1 with db.SessionLocal()() as s: synthetic = ( s.query(Batch) .filter(Batch.input_filename == "") .all() ) assert len(synthetic) == 1 assert synthetic[0].transaction_set_control_number == "991102988" def test_reconcile_is_idempotent(): """Re-running reconcile after a successful pass is a no-op: ``created=0, skipped=N``.""" with db.SessionLocal()() as s: _ingest_999(s, "991102989") s.commit() CycloneStore().reconcile_orphan_st02s(dry_run=False) plan2 = CycloneStore().reconcile_orphan_st02s(dry_run=False) assert plan2["created"] == 0 assert plan2["skipped"] == 1 with db.SessionLocal()() as s: n = ( s.query(Batch) .filter(Batch.input_filename == "") .count() ) assert n == 1 def test_reconcile_dry_run_does_not_write(): """``dry_run=True`` returns the same plan shape but does not insert any rows. Operators use this to preview the reconcile before committing.""" with db.SessionLocal()() as s: _ingest_999(s, "991102989") s.commit() plan = CycloneStore().reconcile_orphan_st02s(dry_run=True) assert plan["created"] == 1 assert plan["skipped"] == 0 with db.SessionLocal()() as s: n = ( s.query(Batch) .filter(Batch.input_filename == "") .count() ) assert n == 0 # no row inserted def test_reconcile_records_ack_count_in_totals_json(): """The synthetic row's ``totals_json`` includes ``ack_count`` so future operators can see the orphan weight without re-querying.""" import json with db.SessionLocal()() as s: for _ in range(4): _ingest_999(s, "991102989") s.commit() CycloneStore().reconcile_orphan_st02s(dry_run=False) with db.SessionLocal()() as s: row = ( s.query(Batch) .filter(Batch.transaction_set_control_number == "991102989") .one() ) totals = json.loads(row.totals_json or "{}") assert totals.get("orphan_reconcile") is True assert totals.get("ack_count") == 4 def test_reconcile_sentinel_is_grep_discoverable(): """``input_filename = ''`` must be discoverable via a plain ``LIKE ''`` query so operators can find every sp38-created row without knowing the exact sentinel string. Pins the spec's D2 grep-discoverability invariant.""" with db.SessionLocal()() as s: _ingest_999(s, "991102988") s.commit() CycloneStore().reconcile_orphan_st02s(dry_run=False) with db.SessionLocal()() as s: n = ( s.query(Batch) .filter(Batch.input_filename.like("")) .count() ) assert n == 1 def test_summary_skips_999s_with_non_dict_raw_json(): """``_extract_999_st02`` must tolerate the un-dict shapes a JSON column can take (None, list) and never raise. Rows with non-dict raw_json are skipped — they have no ST02 to contribute to the summary regardless. Note: the SQLAlchemy JSON column rejects bytes at insert time (it must be JSON-serializable), so we don't exercise the ``bytes`` branch here — that's a defensive-programming guard for raw sqlite3 reads, not a normal ORM codepath. """ with db.SessionLocal()() as s: # Valid orphan — contributes 1 row _ingest_999(s, "991102988") # None — should be skipped, not raise s.add(Ack( source_batch_id="999-none-raw", accepted_count=1, rejected_count=0, received_count=1, ack_code="A", parsed_at=_now(), raw_json=None, )) # list — not a dict; should be skipped (not raise) s.add(Ack( source_batch_id="999-list-raw", accepted_count=1, rejected_count=0, received_count=1, ack_code="A", parsed_at=_now(), raw_json=[1, 2, 3], )) s.commit() summary = CycloneStore().find_ack_orphan_st02_summary() by_st02 = {row["st02"]: row for row in summary} assert by_st02 == {"991102988": {"st02": "991102988", "ack_count": 1, "has_batch": False, "batch_id": None}}