""" Tests for dead man's switch background enforcement and CLI command. Covers: - _deadman_enforcement_loop: does not call execute_purge when not armed - _deadman_enforcement_loop: calls execute_purge when armed and overdue - _deadman_enforcement_loop: exits after firing so execute_purge is not called twice - _start_deadman_thread: returns a live daemon thread - check-deadman CLI: exits 0 when disarmed - check-deadman CLI: exits 0 and prints OK when armed but not overdue - check-deadman CLI: exits 0 and prints OVERDUE warning when past interval but in grace - check-deadman CLI: exits 2 when fully expired (past interval + grace) """ from __future__ import annotations import json import time from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from click.testing import CliRunner # ── Fixtures ──────────────────────────────────────────────────────────────── @pytest.fixture() def soosef_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect soosef paths to a tmp directory.""" import soosef.paths as paths data_dir = tmp_path / ".soosef" data_dir.mkdir() monkeypatch.setattr(paths, "BASE_DIR", data_dir) return data_dir def _write_deadman_state( state_file: Path, *, armed: bool, last_checkin: datetime, interval_hours: int = 24, grace_hours: int = 2, ) -> None: state_file.parent.mkdir(parents=True, exist_ok=True) state = { "armed": armed, "last_checkin": last_checkin.isoformat(), "interval_hours": interval_hours, "grace_hours": grace_hours, } state_file.write_text(json.dumps(state)) # ── Unit tests: enforcement loop ───────────────────────────────────────────── def test_enforcement_loop_no_op_when_disarmed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Loop should not call check() when the switch is not armed.""" from soosef.cli import _deadman_enforcement_loop from soosef.fieldkit import deadman as deadman_mod # Redirect the module-level DEADMAN_STATE constant so DeadmanSwitch() default is our tmp file state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) check_calls = [] def fake_check(self): check_calls.append("fired") monkeypatch.setattr(deadman_mod.DeadmanSwitch, "check", fake_check) iterations = [0] def one_shot_sleep(n): iterations[0] += 1 if iterations[0] >= 2: raise StopIteration("stop test loop") monkeypatch.setattr(time, "sleep", one_shot_sleep) with pytest.raises(StopIteration): _deadman_enforcement_loop(interval_seconds=0) assert check_calls == [] def test_enforcement_loop_fires_when_overdue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Loop must call DeadmanSwitch.check() when armed and past interval + grace.""" from soosef.cli import _deadman_enforcement_loop from soosef.fieldkit import deadman as deadman_mod state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) last_checkin = datetime.now(UTC) - timedelta(hours=100) _write_deadman_state( state_file, armed=True, last_checkin=last_checkin, interval_hours=24, grace_hours=2, ) check_calls = [] def fake_check(self): check_calls.append("fired") monkeypatch.setattr(deadman_mod.DeadmanSwitch, "check", fake_check) monkeypatch.setattr(time, "sleep", lambda n: None) _deadman_enforcement_loop(interval_seconds=0) assert len(check_calls) == 1 def test_enforcement_loop_exits_after_firing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """After firing, the loop must return and not call check() a second time.""" from soosef.cli import _deadman_enforcement_loop from soosef.fieldkit import deadman as deadman_mod state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) last_checkin = datetime.now(UTC) - timedelta(hours=100) _write_deadman_state(state_file, armed=True, last_checkin=last_checkin) check_calls = [] def fake_check(self): check_calls.append("fired") monkeypatch.setattr(deadman_mod.DeadmanSwitch, "check", fake_check) monkeypatch.setattr(time, "sleep", lambda n: None) _deadman_enforcement_loop(interval_seconds=0) # Called exactly once — loop exited after firing assert len(check_calls) == 1 def test_enforcement_loop_tolerates_exceptions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Transient errors in check() must not kill the loop.""" from soosef.cli import _deadman_enforcement_loop from soosef.fieldkit import deadman as deadman_mod state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) call_count = [0] def counting_sleep(n): call_count[0] += 1 if call_count[0] >= 3: raise StopIteration("stop test loop") monkeypatch.setattr(time, "sleep", counting_sleep) error_calls = [0] def flaky_is_armed(self): error_calls[0] += 1 if error_calls[0] == 1: raise OSError("state file temporarily unreadable") return False # not armed — loop just skips monkeypatch.setattr(deadman_mod.DeadmanSwitch, "is_armed", flaky_is_armed) with pytest.raises(StopIteration): _deadman_enforcement_loop(interval_seconds=0) # Should have survived the first exception and continued assert call_count[0] >= 2 # ── Unit tests: _start_deadman_thread ──────────────────────────────────────── def test_start_deadman_thread_is_daemon(monkeypatch: pytest.MonkeyPatch): """Thread must be a daemon so it dies with the process.""" # Patch the loop to exit immediately so the thread doesn't hang in tests import soosef.cli as cli_mod from soosef.cli import _start_deadman_thread monkeypatch.setattr(cli_mod, "_deadman_enforcement_loop", lambda interval_seconds: None) t = _start_deadman_thread(interval_seconds=60) assert t is not None assert t.daemon is True assert t.name == "deadman-enforcement" t.join(timeout=2) # ── CLI integration: check-deadman ─────────────────────────────────────────── @pytest.fixture() def cli_runner(): return CliRunner() def test_check_deadman_disarmed( tmp_path: Path, cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ): """check-deadman exits 0 and prints helpful message when not armed.""" from soosef.cli import main from soosef.fieldkit import deadman as deadman_mod # Point at an empty tmp dir so the real ~/.soosef/fieldkit/deadman.json isn't read state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) result = cli_runner.invoke(main, ["fieldkit", "check-deadman"]) assert result.exit_code == 0 assert "not armed" in result.output def test_check_deadman_armed_ok( tmp_path: Path, cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ): """check-deadman exits 0 when armed and check-in is current.""" from soosef.cli import main from soosef.fieldkit import deadman as deadman_mod state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) last_checkin = datetime.now(UTC) - timedelta(hours=1) _write_deadman_state( state_file, armed=True, last_checkin=last_checkin, interval_hours=24, grace_hours=2, ) result = cli_runner.invoke(main, ["fieldkit", "check-deadman"]) assert result.exit_code == 0 assert "OK" in result.output def test_check_deadman_overdue_in_grace( tmp_path: Path, cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ): """check-deadman exits 0 but prints OVERDUE warning when past interval but in grace.""" from soosef.cli import main from soosef.fieldkit import deadman as deadman_mod state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) # Past 24h interval but within 26h total (grace=2) last_checkin = datetime.now(UTC) - timedelta(hours=25) _write_deadman_state( state_file, armed=True, last_checkin=last_checkin, interval_hours=24, grace_hours=2, ) result = cli_runner.invoke(main, ["fieldkit", "check-deadman"]) # Not yet fired (grace not expired), so exit code is 0 assert result.exit_code == 0 assert "OVERDUE" in result.output def test_check_deadman_fires_when_expired( tmp_path: Path, cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ): """check-deadman exits 2 when the switch has fully expired.""" from soosef.cli import main from soosef.fieldkit import deadman as deadman_mod state_file = tmp_path / "deadman.json" monkeypatch.setattr(deadman_mod, "_paths", type("P", (), {"DEADMAN_STATE": state_file})) last_checkin = datetime.now(UTC) - timedelta(hours=100) _write_deadman_state( state_file, armed=True, last_checkin=last_checkin, interval_hours=24, grace_hours=2, ) # Patch check() so we don't invoke the real killswitch during tests monkeypatch.setattr(deadman_mod.DeadmanSwitch, "check", lambda self: None) result = cli_runner.invoke(main, ["fieldkit", "check-deadman"]) assert result.exit_code == 2 assert "killswitch triggered" in result.output.lower() or "expired" in result.output.lower()