"""SP33: apply_999_rejections must use batch_envelope_index (mirrors SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade to claim state transitions. The pre-SP33 bug: ``claim_lookup(sr.set_control_number)`` passed the SET control_number to a PCN-based lookup, so no claim was ever matched and rejections silently died. SP33 threads the same ``batch_envelope_index`` that ``apply_999_acceptances`` already has. """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import MagicMock from cyclone.db import ClaimState from cyclone.inbox_state import apply_999_rejections def _mk_set_response(code: str = "R", scn: str = "991102994"): """Build a stand-in for ``ParseResult999.set_responses[0]``.""" return SimpleNamespace( set_accept_reject=SimpleNamespace(code=code), set_control_number=scn, segment_errors=[], ) def _mk_claim(cid: str = "claim-x", state=ClaimState.SUBMITTED): c = MagicMock() c.id = cid c.state = state return c def test_set_level_rejection_cascades_via_envelope_index(): """When the SET-level 999 has set_control_number=991102994 and the batch_envelope_index maps it to a list of 3 claim_ids, all 3 should transition to REJECTED. """ session = MagicMock() claims = [_mk_claim(f"c-{i}") for i in range(3)] session.query.return_value.filter.return_value.all.return_value = claims parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")]) index = {"991102994": [c.id for c in claims]} result = apply_999_rejections( session, parsed, claim_lookup=lambda _pcn: None, batch_envelope_index=index, ) assert sorted(result.matched) == sorted(c.id for c in claims) assert result.orphans == [] session.commit.assert_called_once() for c in claims: # ClaimState.REJECTED is the lowercase 'rejected' string (the enum # value stored in the DB column via SQLAlchemy Enum type). assert c.state == ClaimState.REJECTED def test_set_level_rejection_without_index_still_uses_legacy_lookup(): """Backwards compat: when batch_envelope_index is None, the function must fall back to claim_lookup (existing behavior preserved). """ session = MagicMock() parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")]) legacy_claim = _mk_claim("legacy-claim") result = apply_999_rejections( session, parsed, claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None, ) assert result.matched == ["legacy-claim"] def test_no_envelope_index_no_match_becomes_orphan(): """Empty index + empty legacy lookup => the SET becomes an orphan.""" session = MagicMock() parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102999")]) result = apply_999_rejections( session, parsed, claim_lookup=lambda _pcn: None, batch_envelope_index={}, # empty index ) assert result.matched == [] assert "991102999" in result.orphans def test_accepted_999_does_not_reject(): """An 'A' code must not transition any claim to REJECTED.""" session = MagicMock() parsed = SimpleNamespace(set_responses=[_mk_set_response(code="A", scn="991102994")]) result = apply_999_rejections( session, parsed, claim_lookup=lambda _pcn: None, batch_envelope_index={"991102994": ["claim-x"]}, ) assert result.matched == [] session.query.assert_not_called() session.commit.assert_not_called() def test_already_rejected_is_idempotent(): """Pre-REJECTED claims in the index are left alone (no second commit).""" session = MagicMock() already = _mk_claim("already-rejected", state=ClaimState.REJECTED) session.query.return_value.filter.return_value.all.return_value = [already] parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")]) result = apply_999_rejections( session, parsed, claim_lookup=lambda _pcn: None, batch_envelope_index={"991102994": ["already-rejected"]}, ) assert result.matched == [] assert already.state == ClaimState.REJECTED # untouched