diff --git a/src/aipass/api/tests/test_integrations.py b/src/aipass/api/tests/test_integrations.py index 31d0ca01..7bfcf0ba 100644 --- a/src/aipass/api/tests/test_integrations.py +++ b/src/aipass/api/tests/test_integrations.py @@ -171,6 +171,29 @@ class TestFetchContracts: assert result["contracts"] == ["alpha", "beta"] assert result["count"] == 2 + def test_get_contracts_returns_expected_shape(self): + """get_contracts() result dict has contracts, count, and success keys.""" + bridge.register("shaped", lambda: None) + result = fetch_contracts() + assert isinstance(result["contracts"], list) + assert isinstance(result["count"], int) + assert isinstance(result["success"], bool) + assert result["count"] == len(result["contracts"]) + + def test_get_contracts_handles_logging_failure(self): + """get_contracts() returns graceful failure when json_handler raises.""" + from unittest.mock import patch + + bridge.register("test_logging", lambda: None) + with patch( + "aipass.api.apps.handlers.integrations.list.json_handler.log_operation", + side_effect=RuntimeError("log broke"), + ): + result = fetch_contracts() + assert result["success"] is False + assert result["contracts"] == [] + assert result["count"] == 0 + # --------------------------------------------------------------------------- # TestCallContract diff --git a/src/aipass/flow/tests/test_lock_ops.py b/src/aipass/flow/tests/test_lock_ops.py new file mode 100644 index 00000000..ba589c53 --- /dev/null +++ b/src/aipass/flow/tests/test_lock_ops.py @@ -0,0 +1,207 @@ +# =================== AIPass ==================== +# Name: test_lock_ops.py +# Description: Tests for lock_ops handler — atomic lock file management +# Version: 1.0.0 +# Created: 2026-04-26 +# Modified: 2026-04-26 +# ============================================= + +"""Tests for lock_ops handler — atomic lock file management.""" + +import os +from pathlib import Path +from unittest.mock import patch + + +# ─── Patch targets ─────────────────────────────────────── +_MOD = "aipass.flow.apps.handlers.runner.lock_ops" + + +def _import_lock_ops(): + """Import lock_ops module and return it.""" + import aipass.flow.apps.handlers.runner.lock_ops as mod + + return mod + + +# ═══════════════════════════════════════════════════════════ +# 1. try_create_lock +# ═══════════════════════════════════════════════════════════ + + +class TestTryCreateLock: + """Tests for try_create_lock — atomic O_CREAT|O_EXCL lock creation.""" + + def test_creates_lock_file_successfully(self, tmp_path): + """Should create lock file with current PID and return True.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + result = mod.try_create_lock(lock) + assert result is True + assert lock.exists() + assert lock.read_text(encoding="utf-8") == str(os.getpid()) + + def test_returns_false_if_lock_exists(self, tmp_path): + """Should return False when lock file already exists.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("12345", encoding="utf-8") + result = mod.try_create_lock(lock) + assert result is False + + def test_does_not_overwrite_existing_lock(self, tmp_path): + """Existing lock content should be preserved on failure.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("99999", encoding="utf-8") + mod.try_create_lock(lock) + assert lock.read_text(encoding="utf-8") == "99999" + + +# ═══════════════════════════════════════════════════════════ +# 2. is_lock_stale +# ═══════════════════════════════════════════════════════════ + + +class TestIsLockStale: + """Tests for is_lock_stale — dead process detection.""" + + def test_lock_with_current_pid_is_not_stale(self, tmp_path): + """Lock file holding current PID should not be considered stale.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text(str(os.getpid()), encoding="utf-8") + result = mod.is_lock_stale(lock) + assert result is False + + def test_lock_with_dead_pid_is_stale(self, tmp_path): + """Lock file holding a non-existent PID should be stale.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("999999999", encoding="utf-8") + with patch(f"{_MOD}.os.kill", side_effect=ProcessLookupError): + result = mod.is_lock_stale(lock) + assert result is True + + def test_lock_with_invalid_content_is_stale(self, tmp_path): + """Lock file with non-integer content should be stale.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("not-a-pid", encoding="utf-8") + result = mod.is_lock_stale(lock) + assert result is True + + def test_lock_with_empty_content_is_stale(self, tmp_path): + """Lock file with empty content should be stale.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("", encoding="utf-8") + result = mod.is_lock_stale(lock) + assert result is True + + def test_permission_error_treated_as_stale(self, tmp_path): + """PermissionError from os.kill should treat lock as stale.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("1", encoding="utf-8") + with patch(f"{_MOD}.os.kill", side_effect=PermissionError): + result = mod.is_lock_stale(lock) + assert result is True + + +# ═══════════════════════════════════════════════════════════ +# 3. acquire_lock +# ═══════════════════════════════════════════════════════════ + + +class TestAcquireLock: + """Tests for acquire_lock — full lock acquisition with stale recovery.""" + + def test_acquires_fresh_lock(self, tmp_path): + """Should acquire lock when no lock file exists.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + result = mod.acquire_lock(lock) + assert result is True + assert lock.exists() + + def test_fails_when_another_process_holds_lock(self, tmp_path): + """Should return False when lock held by a live process.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text(str(os.getpid()), encoding="utf-8") + result = mod.acquire_lock(lock) + assert result is False + + def test_recovers_stale_lock(self, tmp_path): + """Should recover a stale lock (dead PID) and acquire it.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("999999999", encoding="utf-8") + with patch(f"{_MOD}.os.kill", side_effect=ProcessLookupError): + result = mod.acquire_lock(lock) + assert result is True + assert lock.read_text(encoding="utf-8") == str(os.getpid()) + + def test_fails_when_stale_lock_unlink_fails(self, tmp_path): + """Should return False when stale lock can't be removed.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("999999999", encoding="utf-8") + with ( + patch(f"{_MOD}.os.kill", side_effect=ProcessLookupError), + patch.object(Path, "unlink", side_effect=OSError("permission denied")), + ): + result = mod.acquire_lock(lock) + assert result is False + + def test_logs_json_operation_on_fresh_acquire(self, tmp_path, mock_json_handler): + """Should log lock_acquired via json_handler on fresh lock.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + mod.acquire_lock(lock) + mock_json_handler.assert_called() + call_args = mock_json_handler.call_args + assert call_args[0][0] == "lock_acquired" + assert call_args[0][1]["lock_file"] == str(lock) + + def test_logs_stale_recovery_on_stale_acquire(self, tmp_path, mock_json_handler): + """Should log stale_recovery=True when recovering stale lock.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("999999999", encoding="utf-8") + with patch(f"{_MOD}.os.kill", side_effect=ProcessLookupError): + mod.acquire_lock(lock) + call_args = mock_json_handler.call_args + assert call_args[0][1]["stale_recovery"] is True + + +# ═══════════════════════════════════════════════════════════ +# 4. release_lock +# ═══════════════════════════════════════════════════════════ + + +class TestReleaseLock: + """Tests for release_lock — lock file cleanup.""" + + def test_removes_existing_lock(self, tmp_path): + """Should remove the lock file.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text(str(os.getpid()), encoding="utf-8") + mod.release_lock(lock) + assert not lock.exists() + + def test_no_error_when_lock_missing(self, tmp_path): + """Should handle missing lock file gracefully (missing_ok=True).""" + mod = _import_lock_ops() + lock = tmp_path / ".nonexistent.lock" + mod.release_lock(lock) + + def test_logs_warning_on_os_error(self, tmp_path, mock_logger): + """Should log warning when lock removal fails.""" + mod = _import_lock_ops() + lock = tmp_path / ".test.lock" + lock.write_text("12345", encoding="utf-8") + with patch.object(Path, "unlink", side_effect=OSError("disk error")): + mod.release_lock(lock)