""" Tests for shaliach + tevilah — property 4 (role-separated operator) of azazel_engineering_reading.md §14. The disposal handler (IshItti, "designated man") is the ONLY role authorized to invoke disposal-channel functions. The handler accumulates state during a disposal (payload references, intermediate tags) that MUST be cleared by tevilah before the handler can be re-used or before the same process touches non-azazel code. The CI invariant — test_tevilah_clears_all_non_immutable_state — catches future contributors who add state-bearing attributes to IshItti without extending tevilah() to clear them. Without that invariant, the property would silently degrade as the codebase evolves. """ import pytest from azazel_processor.shaliach import ( IshItti, ShaliachError, assert_shaliach_active, ) # ============================================================ # Construction # ============================================================ def test_handler_requires_named_identifier(): h = IshItti("aharon") assert h.identifier == "aharon" def test_handler_refuses_empty_identifier(): with pytest.raises(ShaliachError): IshItti("") def test_handler_refuses_whitespace_identifier(): with pytest.raises(ShaliachError): IshItti(" ") def test_handler_refuses_non_string_identifier(): with pytest.raises(ShaliachError): IshItti(None) # type: ignore[arg-type] with pytest.raises(ShaliachError): IshItti(123) # type: ignore[arg-type] def test_identifier_is_immutable(): """The identifier is set at construction and propagates onto AzazelRecords for shaliach attribution. It must not be re-assignable, and it must survive tevilah.""" h = IshItti("aharon") h.activate() h.tevilah() assert h.identifier == "aharon" # ============================================================ # Activation lifecycle # ============================================================ def test_handler_starts_inactive(): h = IshItti("aharon") assert h.is_active() is False def test_activate_marks_handler_active(): h = IshItti("aharon") h.activate() assert h.is_active() is True def test_activate_when_already_active_raises(): """Re-activation without tevilah would mix two disposals' state. Refuse — the handler must wash before rejoining (Vayikra 16:26).""" h = IshItti("aharon") h.activate() with pytest.raises(ShaliachError) as exc: h.activate() assert "tevilah" in str(exc.value).lower() def test_tevilah_then_reactivate_succeeds(): """The handler can perform many disposals across its lifetime, one at a time, with mandatory tevilah between.""" h = IshItti("aharon") h.activate() h.tevilah() assert h.is_active() is False h.activate() assert h.is_active() is True # ============================================================ # Hold payload + tag — only when active # ============================================================ def test_hold_payload_requires_active(): h = IshItti("aharon") with pytest.raises(ShaliachError): h.hold_payload({"data": "x"}) def test_hold_payload_tracks_references(): h = IshItti("aharon") h.activate() h.hold_payload({"data": "first"}) h.hold_payload({"data": "second"}) assert h.held_payload_count() == 2 def test_tag_requires_active(): h = IshItti("aharon") with pytest.raises(ShaliachError): h.tag("checkpoint", "STAGED") def test_tag_round_trip(): h = IshItti("aharon") h.activate() h.tag("checkpoint", "STAGED") assert h.get_tag("checkpoint") == "STAGED" assert h.get_tag("missing") is None assert h.get_tag("missing", "default") == "default" # ============================================================ # Tevilah — the load-bearing reset # ============================================================ def test_tevilah_clears_payload_refs(): h = IshItti("aharon") h.activate() h.hold_payload({"data": "x"}) assert h.held_payload_count() == 1 h.tevilah() assert h.held_payload_count() == 0 def test_tevilah_clears_intermediate_tags(): h = IshItti("aharon") h.activate() h.tag("checkpoint", "STAGED") h.tag("vidui_id", "abc") h.tevilah() h.activate() # re-activate to allow get_tag (which doesn't actually require active, but for clarity) assert h.get_tag("checkpoint") is None assert h.get_tag("vidui_id") is None def test_tevilah_returns_handler_to_inactive(): h = IshItti("aharon") h.activate() h.tevilah() assert h.is_active() is False def test_tevilah_preserves_immutable_identifier(): h = IshItti("aharon") h.activate() h.tevilah() assert h.identifier == "aharon" # ============================================================ # THE LOAD-BEARING CI INVARIANT # ============================================================ def test_tevilah_clears_all_non_immutable_state(): """Property 4's structural defense: ANY attribute on IshItti that does not start with `_immutable_` MUST be reset to a fresh value (or removed entirely) by tevilah(). A future contributor who adds `self._cache = {}` (or any other state attribute) to IshItti without extending tevilah() to clear it breaks the property — and this test fires before the regression ships. The discovery is dynamic: we walk the handler's `vars()` after pre-tevilah state has been seeded, then verify each non-immutable attribute is in a 'fresh' state post-tevilah. """ h = IshItti("aharon") h.activate() # Seed every state-bearing attribute we know about. h.hold_payload({"data": "first"}) h.hold_payload({"data": "second"}) h.tag("checkpoint", "STAGED") h.tag("disposal_id", "abc-123") # Snapshot the pre-tevilah attribute set. pre_tevilah = dict(vars(h)) # Sanity: at least one non-immutable attribute should be non-fresh. non_immutable = { k: v for k, v in pre_tevilah.items() if not k.startswith("_immutable_") } assert any(_is_non_fresh(v) for v in non_immutable.values()), ( "Test setup error: no non-fresh state to clear" ) h.tevilah() # Post-tevilah: every non-immutable attribute must be 'fresh'. for attr_name, value in vars(h).items(): if attr_name.startswith("_immutable_"): continue assert _is_fresh(value), ( f"tevilah() did not reset {attr_name}: value is {value!r}. " f"If this attribute was added without extending tevilah, " f"that is the property-4 regression. Either prefix the " f"name with `_immutable_` (if it is set once at " f"construction) or extend tevilah() to clear it." ) # All immutable attributes must STILL be present. for attr_name in pre_tevilah: if attr_name.startswith("_immutable_"): assert attr_name in vars(h), ( f"tevilah() incorrectly removed immutable attribute {attr_name!r}." ) def _is_fresh(value) -> bool: """A value is 'fresh' if it is the canonical empty value for its type — empty container, False, None, 0.""" if value is None or value is False: return True if value == 0: return True if isinstance(value, (list, tuple, set, dict, str, bytes)) and len(value) == 0: return True return False def _is_non_fresh(value) -> bool: return not _is_fresh(value) # ============================================================ # assert_shaliach_active — the role gate # ============================================================ def test_assert_shaliach_active_passes_for_active_ish_itti(): h = IshItti("aharon") h.activate() # Should not raise assert_shaliach_active(h) def test_assert_shaliach_active_refuses_inactive_handler(): h = IshItti("aharon") with pytest.raises(ShaliachError) as exc: assert_shaliach_active(h) assert "not active" in str(exc.value).lower() def test_assert_shaliach_active_refuses_non_ish_itti(): """A kohen-shaped or owner-shaped value passed where a shaliach is expected — refuse. Property 4: cross-role invocation is forbidden.""" not_a_shaliach = "string-not-an-IshItti" with pytest.raises(ShaliachError) as exc: assert_shaliach_active(not_a_shaliach) # type: ignore[arg-type] assert "ishitti" in str(exc.value).lower() or "shaliach" in str(exc.value).lower() def test_assert_shaliach_active_refuses_object_impersonating_handler(): """A duck-typed object with the same method names is NOT an IshItti. The role gate is identity, not interface.""" class DuckIshItti: identifier = "fake" def is_active(self): return True with pytest.raises(ShaliachError): assert_shaliach_active(DuckIshItti()) # type: ignore[arg-type] # ============================================================ # Stale-state-across-disposals detection # ============================================================ def test_stale_state_across_disposals_is_prevented(): """The integration property: state from disposal-N must not leak into disposal-N+1. tevilah is the gate; this test verifies it works end-to-end.""" h = IshItti("aharon") # Disposal 1 h.activate() h.hold_payload({"id": 1, "data": "first"}) h.tag("disposal_id", "first") h.tevilah() # Disposal 2 — must start fresh h.activate() assert h.held_payload_count() == 0 assert h.get_tag("disposal_id") is None h.hold_payload({"id": 2, "data": "second"}) h.tag("disposal_id", "second") assert h.held_payload_count() == 1 assert h.get_tag("disposal_id") == "second"