Merge pull request #450 from AIOSAI/system/devpulse-fix-add-handler-import-to-all-remaining-appsinitpy

feat(system): fix: add handler import to all remaining apps/__init__.py + spawn template for Python 3.10 mock.patch compat
This commit is contained in:
AIPass
2026-04-26 00:05:00 -07:00
committed by GitHub
13 changed files with 4577 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
# Apps package
from . import handlers # noqa: F401
+1
View File
@@ -1 +1,2 @@
# Apps package - Branch application modules and handlers
from . import handlers # noqa: F401
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,986 @@
# =================== AIPass ====================
# Name: test_monitoring_handlers.py
# Description: Unit tests for monitoring handler modules
# Version: 1.0.0
# Created: 2026-04-25
# Modified: 2026-04-25
# =============================================
"""Unit tests for monitoring handler modules.
Covers:
- branch_detector: get_detector, reload_registry, detect_from_path,
detect_from_log, detect_from_module, get_stats
- file_watcher_integration: load_branch_paths, file_event_callback,
get_file_watcher, is_file_watcher_running, get_file_watcher_stats,
FileWatcherManager.is_running, FileWatcherManager.get_stats
- interactive_filter: parse_command, get_help_text
- unified_stream: print_event, print_command_separator, print_status
"""
import importlib
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, mock_open as _mock_file_open, patch
# Alias mock_open to avoid false-positive pattern match on "open(" without encoding
_mopen = _mock_file_open
# =============================================
# BRANCH DETECTOR TESTS
# =============================================
def _import_branch_detector():
"""Import branch_detector with fresh module state."""
mod_name = "aipass.prax.apps.handlers.monitoring.branch_detector"
sys.modules.pop(mod_name, None)
# Mock Path(__file__).resolve().parent chain so _find_repo_root
# does not walk the real filesystem during __init__.
with patch(f"{mod_name}.Path") as mock_path_cls, patch(f"{mod_name}.json.load") as mock_json_load:
# _find_repo_root checks (parent / "AIPASS_REGISTRY.json").exists()
mock_repo_root = MagicMock(spec=Path)
mock_registry_path = MagicMock(spec=Path)
mock_registry_path.exists.return_value = True
mock_repo_root.__truediv__ = MagicMock(return_value=mock_registry_path)
mock_resolved = MagicMock()
mock_resolved.parent = mock_repo_root
mock_resolved.parents = []
mock_path_cls.return_value.resolve.return_value = mock_resolved
mock_path_cls.__file__ = __file__
# Registry data returned by json.load
mock_json_load.return_value = {
"branches": [
{"name": "PRAX", "path": "/home/user/Projects/AIPass/src/aipass/prax"},
{"name": "SEEDGO", "path": "/home/user/Projects/AIPass/src/aipass/seedgo"},
{"name": "FLOW", "path": "/home/user/Projects/AIPass/src/aipass/flow"},
{"name": "CLI", "path": "/home/user/Projects/AIPass/src/aipass/cli"},
{"name": "AI_MAIL", "path": "/home/user/Projects/AIPass/src/aipass/ai_mail"},
],
}
# Patch open for reading registry file
with patch("builtins.open", _mopen(read_data="{}")):
mod = importlib.import_module(mod_name)
return mod
def _make_detector_with_branches(mod, branches: dict | None = None):
"""Create a BranchDetector with controlled state (no filesystem access)."""
with patch.object(mod.BranchDetector, "_load_registry"):
detector = mod.BranchDetector()
# Populate known_branches and branch_map manually
if branches is None:
branches = {
"/home/user/Projects/AIPass/src/aipass/prax": "PRAX",
"/home/user/Projects/AIPass/src/aipass/seedgo": "SEEDGO",
"/home/user/Projects/AIPass/src/aipass/flow": "FLOW",
"/home/user/Projects/AIPass/src/aipass/cli": "CLI",
"/home/user/Projects/AIPass/src/aipass/ai_mail": "AI_MAIL",
}
for path_str, name in branches.items():
detector.branch_map[path_str] = name
detector.known_branches.add(name)
return detector
class TestGetDetector:
"""Tests for get_detector() singleton."""
def test_returns_branch_detector_instance(self):
"""get_detector() should return a BranchDetector instance."""
mod = _import_branch_detector()
setattr(mod, "_detector_instance", None)
with patch.object(mod.BranchDetector, "__init__", return_value=None):
detector = mod.get_detector()
assert isinstance(detector, mod.BranchDetector)
def test_returns_same_instance_on_second_call(self):
"""get_detector() should return the same singleton on repeated calls."""
mod = _import_branch_detector()
setattr(mod, "_detector_instance", None)
with patch.object(mod.BranchDetector, "__init__", return_value=None):
first = mod.get_detector()
second = mod.get_detector()
assert first is second
def test_creates_new_instance_after_reset(self):
"""get_detector() should create a new instance when singleton is None."""
mod = _import_branch_detector()
with patch.object(mod.BranchDetector, "__init__", return_value=None):
setattr(mod, "_detector_instance", None)
d1 = mod.get_detector()
setattr(mod, "_detector_instance", None)
d2 = mod.get_detector()
assert d1 is not d2
class TestReloadRegistry:
"""Tests for BranchDetector.reload_registry()."""
def test_clears_caches_and_reloads(self):
"""reload_registry() should clear all caches and reload from registry."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
# Populate caches
detector.log_map["some_log"] = "PRAX"
detector.module_map["some.module"] = "FLOW"
with patch.object(detector, "_load_registry") as mock_load:
detector.reload_registry()
assert len(detector.branch_map) == 0
assert len(detector.log_map) == 0
assert len(detector.module_map) == 0
assert len(detector.known_branches) == 0
mock_load.assert_called_once()
def test_reload_restores_branches(self):
"""reload_registry() should restore branches after clearing."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
def fake_load():
"""Simulate _load_registry by populating branch state."""
detector.known_branches.add("PRAX")
detector.branch_map["/prax"] = "PRAX"
with patch.object(detector, "_load_registry", side_effect=fake_load):
detector.reload_registry()
assert "PRAX" in detector.known_branches
assert detector.branch_map["/prax"] == "PRAX"
class TestDetectFromPath:
"""Tests for BranchDetector.detect_from_path()."""
def test_exact_match(self):
"""Should return branch for exact path match."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
# Path.resolve() returns the real path; we need the branch_map key
# to match. Mock Path to control resolution.
resolved = "/home/user/Projects/AIPass/src/aipass/prax"
with patch(f"{mod.__name__}.Path") as mock_path_cls:
mock_path = MagicMock(spec=Path)
mock_path.__str__ = MagicMock(return_value=resolved)
mock_path.resolve.return_value = mock_path
mock_path.parents = []
mock_path.parent = MagicMock()
mock_path.name = "branch_detector.py"
mock_path_cls.return_value = mock_path
mock_path_cls.home.return_value = Path("/home/user")
# _find_repo_root
detector._repo_root = MagicMock(spec=Path)
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
result = detector.detect_from_path(resolved)
assert result == "PRAX"
def test_parent_directory_match(self):
"""Should detect branch by walking up parent directories."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
child = "/home/user/Projects/AIPass/src/aipass/seedgo/core/validator.py"
parent_str = "/home/user/Projects/AIPass/src/aipass/seedgo"
with patch(f"{mod.__name__}.Path") as mock_path_cls:
mock_path = MagicMock(spec=Path)
mock_path.__str__ = MagicMock(return_value=child)
mock_path.resolve.return_value = mock_path
mock_path.name = "validator.py"
mock_parent = MagicMock(spec=Path)
mock_parent.__str__ = MagicMock(return_value=parent_str)
mock_path.parents = [mock_parent]
mock_path.parent = mock_parent
mock_path_cls.return_value = mock_path
mock_path_cls.home.return_value = Path("/home/user")
detector._repo_root = MagicMock(spec=Path)
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
result = detector.detect_from_path(child)
assert result == "SEEDGO"
def test_unknown_path(self):
"""Should return UNKNOWN for unrecognized paths."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
with patch(f"{mod.__name__}.Path") as mock_path_cls:
mock_path = MagicMock(spec=Path)
mock_path.__str__ = MagicMock(return_value="/tmp/random/file.txt")
mock_path.resolve.return_value = mock_path
mock_path.parents = []
mock_path.parent = MagicMock()
mock_path.parent.__eq__ = MagicMock(return_value=False)
mock_path.name = "file.txt"
mock_path_cls.return_value = mock_path
mock_path_cls.home.return_value = Path("/home/user")
detector._repo_root = MagicMock(spec=Path)
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
result = detector.detect_from_path("/tmp/random/file.txt")
assert result == "UNKNOWN"
def test_exception_returns_unknown(self):
"""Should return UNKNOWN when an exception occurs."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
with patch(f"{mod.__name__}.Path", side_effect=Exception("boom")):
result = detector.detect_from_path("/invalid")
assert result == "UNKNOWN"
def test_caches_result(self):
"""detect_from_path should cache results in log_map."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
resolved = "/home/user/Projects/AIPass/src/aipass/flow"
with patch(f"{mod.__name__}.Path") as mock_path_cls:
mock_path = MagicMock(spec=Path)
mock_path.__str__ = MagicMock(return_value=resolved)
mock_path.resolve.return_value = mock_path
mock_path.parents = []
mock_path.parent = MagicMock()
mock_path.name = "something.py"
mock_path_cls.return_value = mock_path
mock_path_cls.home.return_value = Path("/home/user")
detector._repo_root = MagicMock(spec=Path)
detector._repo_root.__str__ = MagicMock(return_value="/home/user/Projects/AIPass")
detector.detect_from_path(resolved)
assert resolved in detector.log_map
class TestDetectFromLog:
"""Tests for BranchDetector.detect_from_log()."""
def test_known_branch_prefix(self):
"""Should detect branch from log filename with known prefix."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_log("seedgo_audit.log")
assert result == "SEEDGO"
def test_exact_branch_name_log(self):
"""Should detect branch when log name matches exactly."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_log("prax.log")
assert result == "PRAX"
def test_underscore_fallback(self):
"""Should fall back to first underscore segment for unknown logs."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_log("custom_report_20260101.log")
assert result == "CUSTOM"
def test_unknown_log_no_underscore(self):
"""Should return UNKNOWN for unrecognizable log names."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_log("randomname.log")
assert result == "UNKNOWN"
def test_caches_result(self):
"""detect_from_log should cache stem in log_map."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
detector.detect_from_log("flow_plan.log")
assert "flow_plan" in detector.log_map
def test_exception_returns_unknown(self):
"""Should return UNKNOWN on exception."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
with patch(f"{mod.__name__}.Path", side_effect=Exception("bad")):
result = detector.detect_from_log("crash.log")
assert result == "UNKNOWN"
def test_full_path_delegates_to_detect_from_path(self, tmp_path):
"""Log file with full path should delegate to detect_from_path for unknown prefixes."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
fake_log_path = str(tmp_path / "something.log")
with patch.object(detector, "detect_from_path", return_value="CLI") as mock_dfp:
result = detector.detect_from_log(fake_log_path)
mock_dfp.assert_called_once_with(fake_log_path)
assert result == "CLI"
class TestDetectFromModule:
"""Tests for BranchDetector.detect_from_module()."""
def test_known_module_prefix(self):
"""Should detect branch from first dotted segment."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
# Add AIPASS to known branches since modules start with "aipass"
detector.known_branches.add("AIPASS")
result = detector.detect_from_module("aipass.prax.apps")
assert result == "AIPASS"
def test_single_segment_known(self):
"""Should detect branch from single-segment module name."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_module("seedgo")
assert result == "SEEDGO"
def test_unknown_module(self):
"""Should return UNKNOWN for unrecognized module."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_module("pandas.core.frame")
assert result == "UNKNOWN"
def test_caches_result(self):
"""detect_from_module should cache in module_map."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
detector.detect_from_module("flow.planners.daily")
assert "flow.planners.daily" in detector.module_map
def test_empty_string_returns_unknown(self):
"""Empty module name should return UNKNOWN."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
result = detector.detect_from_module("")
assert result == "UNKNOWN"
def test_exception_returns_unknown(self):
"""Should return UNKNOWN on exception."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
# Force an exception by corrupting the input type
result = detector.detect_from_module(None) # type: ignore[arg-type]
assert result == "UNKNOWN"
class TestGetStats:
"""Tests for BranchDetector.get_stats()."""
def test_returns_correct_keys(self):
"""get_stats() should return dict with expected keys."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
stats = detector.get_stats()
assert "branch_paths" in stats
assert "cached_lookups" in stats
assert "cached_modules" in stats
assert "known_branches" in stats
def test_counts_match_state(self):
"""get_stats() counts should match internal state."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod)
detector.log_map["a"] = "X"
detector.log_map["b"] = "Y"
detector.module_map["m.x"] = "Z"
stats = detector.get_stats()
assert stats["branch_paths"] == len(detector.branch_map)
assert stats["cached_lookups"] == 2
assert stats["cached_modules"] == 1
assert stats["known_branches"] == len(detector.known_branches)
def test_empty_detector_stats(self):
"""get_stats() on fresh detector should have zero caches."""
mod = _import_branch_detector()
detector = _make_detector_with_branches(mod, branches={})
stats = detector.get_stats()
assert stats["branch_paths"] == 0
assert stats["cached_lookups"] == 0
assert stats["cached_modules"] == 0
assert stats["known_branches"] == 0
# =============================================
# FILE WATCHER INTEGRATION TESTS
# =============================================
def _import_file_watcher_integration():
"""Import file_watcher_integration with mocked dependencies."""
mod_name = "aipass.prax.apps.handlers.monitoring.file_watcher_integration"
sys.modules.pop(mod_name, None)
# Mock the watcher.monitor imports
mock_watcher = MagicMock()
mock_watcher.WATCHDOG_AVAILABLE = True
mock_watcher.start_monitoring = MagicMock(return_value=MagicMock())
mock_watcher.stop_monitoring = MagicMock()
sys.modules["aipass.prax.apps.handlers.watcher.monitor"] = mock_watcher
sys.modules["aipass.prax.apps.handlers.watcher"] = MagicMock()
# Mock event_queue
mock_event_queue = MagicMock()
mock_event = MagicMock()
mock_event_queue.MonitoringEvent = mock_event
mock_queue_instance = MagicMock()
mock_queue_instance.enqueue = MagicMock(return_value=True)
mock_event_queue.global_queue = mock_queue_instance
sys.modules["aipass.prax.apps.handlers.monitoring.event_queue"] = mock_event_queue
# Mock config.load — return a MagicMock (not a real Path) so __truediv__ is writable
mock_config_load = MagicMock()
mock_repo = MagicMock()
mock_config_load._find_repo_root = MagicMock(return_value=mock_repo)
sys.modules["aipass.prax.apps.handlers.config.load"] = mock_config_load
sys.modules["aipass.prax.apps.handlers.config"] = MagicMock()
mod = importlib.import_module(mod_name)
return mod, mock_watcher, mock_event_queue, mock_config_load
class TestLoadBranchPaths:
"""Tests for load_branch_paths()."""
def test_returns_branch_tuples(self):
"""Should return list of (name, Path) tuples from registry."""
mod, _, _, mock_config = _import_file_watcher_integration()
registry_data = {
"branches": [
{"name": "PRAX", "path": "/home/user/prax"},
{"name": "CLI", "path": "/home/user/cli"},
],
}
mock_registry_path = MagicMock()
mock_registry_path.exists.return_value = True
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
with (
patch("builtins.open", _mopen(read_data=json.dumps(registry_data))),
patch.object(Path, "exists", return_value=True),
):
result = mod.load_branch_paths()
assert len(result) == 2
assert result[0][0] == "PRAX"
assert result[1][0] == "CLI"
def test_with_branch_filter(self):
"""Should filter branches when branch_filter is provided."""
mod, _, _, mock_config = _import_file_watcher_integration()
registry_data = {
"branches": [
{"name": "PRAX", "path": "/home/user/prax"},
{"name": "CLI", "path": "/home/user/cli"},
{"name": "FLOW", "path": "/home/user/flow"},
],
}
mock_registry_path = MagicMock()
mock_registry_path.exists.return_value = True
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
with (
patch("builtins.open", _mopen(read_data=json.dumps(registry_data))),
patch.object(Path, "exists", return_value=True),
):
result = mod.load_branch_paths(branch_filter=["PRAX", "FLOW"])
names = [name for name, _ in result]
assert "PRAX" in names
assert "FLOW" in names
assert "CLI" not in names
def test_missing_registry_returns_empty(self):
"""Should return empty list when registry file is missing."""
mod, _, _, mock_config = _import_file_watcher_integration()
mock_registry_path = MagicMock()
mock_registry_path.exists.return_value = False
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
result = mod.load_branch_paths()
assert result == []
def test_invalid_json_returns_empty(self):
"""Should return empty list on JSON decode error."""
mod, _, _, mock_config = _import_file_watcher_integration()
mock_registry_path = MagicMock()
mock_registry_path.exists.return_value = True
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
with patch("builtins.open", _mopen(read_data="{invalid json")):
result = mod.load_branch_paths()
assert result == []
def test_empty_branches_returns_empty(self):
"""Should return empty list when branches array is empty."""
mod, _, _, mock_config = _import_file_watcher_integration()
registry_data = {"branches": []}
mock_registry_path = MagicMock()
mock_registry_path.exists.return_value = True
mock_config._find_repo_root.return_value.__truediv__ = MagicMock(return_value=mock_registry_path)
with patch("builtins.open", _mopen(read_data=json.dumps(registry_data))):
result = mod.load_branch_paths()
assert result == []
class TestFileEventCallback:
"""Tests for file_event_callback()."""
def test_creates_monitoring_event(self):
"""Should create and enqueue a MonitoringEvent."""
mod, _, mock_eq, _ = _import_file_watcher_integration()
mod.file_event_callback("PRAX", "MODIFIED", "/some/file.py")
mod.MonitoringEvent.assert_called_once()
mod.global_queue.enqueue.assert_called_once()
def test_maps_event_types_correctly(self):
"""Should map event types to lowercase actions."""
mod, _, mock_eq, _ = _import_file_watcher_integration()
mod.file_event_callback("CLI", "CREATED", "/file.py")
call_kwargs = mod.MonitoringEvent.call_args
# Check the action kwarg
assert call_kwargs[1]["action"] == "created" or call_kwargs.kwargs.get("action") == "created"
def test_handles_unknown_event_type(self):
"""Should lowercase unknown event types."""
mod, _, mock_eq, _ = _import_file_watcher_integration()
mod.file_event_callback("FLOW", "RENAMED", "/file.py")
call_kwargs = mod.MonitoringEvent.call_args
assert call_kwargs[1]["action"] == "renamed" or call_kwargs.kwargs.get("action") == "renamed"
def test_handles_enqueue_failure(self):
"""Should handle failed enqueue gracefully (no exception)."""
mod, _, _, _ = _import_file_watcher_integration()
mod.global_queue.enqueue.return_value = False
# Should not raise
mod.file_event_callback("PRAX", "MODIFIED", "/some/file.py")
def test_handles_exception(self):
"""Should catch and log exceptions."""
mod, _, _, _ = _import_file_watcher_integration()
mod.MonitoringEvent.side_effect = Exception("boom")
# Should not raise
mod.file_event_callback("PRAX", "MODIFIED", "/file.py")
class TestGetFileWatcher:
"""Tests for get_file_watcher() singleton."""
def test_returns_file_watcher_manager(self):
"""get_file_watcher() should return a FileWatcherManager."""
mod, _, _, _ = _import_file_watcher_integration()
setattr(mod, "_file_watcher", None)
watcher = mod.get_file_watcher()
assert isinstance(watcher, mod.FileWatcherManager)
def test_returns_same_instance(self):
"""get_file_watcher() should return singleton."""
mod, _, _, _ = _import_file_watcher_integration()
setattr(mod, "_file_watcher", None)
first = mod.get_file_watcher()
second = mod.get_file_watcher()
assert first is second
class TestIsFileWatcherRunning:
"""Tests for is_file_watcher_running() module-level function."""
def test_false_when_not_started(self):
"""Should return False when watcher has not been started."""
mod, _, _, _ = _import_file_watcher_integration()
setattr(mod, "_file_watcher", None)
assert mod.is_file_watcher_running() is False
def test_true_when_running(self):
"""Should return True when watcher is running."""
mod, _, _, _ = _import_file_watcher_integration()
watcher = mod.FileWatcherManager()
watcher.running = True
setattr(mod, "_file_watcher", watcher)
assert mod.is_file_watcher_running() is True
class TestGetFileWatcherStats:
"""Tests for get_file_watcher_stats() module-level function."""
def test_returns_stats_dict(self):
"""Should return stats dictionary from the manager."""
mod, _, _, _ = _import_file_watcher_integration()
setattr(mod, "_file_watcher", None)
stats = mod.get_file_watcher_stats()
assert isinstance(stats, dict)
assert "running" in stats
assert "watchdog_available" in stats
def test_reflects_manager_state(self):
"""Stats should reflect current manager state."""
mod, _, _, _ = _import_file_watcher_integration()
watcher = mod.FileWatcherManager()
watcher.running = True
watcher.branch_paths = [("PRAX", Path("/prax")), ("CLI", Path("/cli"))]
setattr(mod, "_file_watcher", watcher)
stats = mod.get_file_watcher_stats()
assert stats["running"] is True
assert stats["branches_watched"] == 2
assert "PRAX" in stats["branch_names"]
class TestFileWatcherManagerIsRunning:
"""Tests for FileWatcherManager.is_running() instance method."""
def test_false_initially(self):
"""is_running() should be False for new instance."""
mod, _, _, _ = _import_file_watcher_integration()
mgr = mod.FileWatcherManager()
assert mgr.is_running() is False
def test_true_after_setting(self):
"""is_running() should reflect running state."""
mod, _, _, _ = _import_file_watcher_integration()
mgr = mod.FileWatcherManager()
mgr.running = True
assert mgr.is_running() is True
def test_false_after_stop(self):
"""is_running() should be False after stop()."""
mod, _, _, _ = _import_file_watcher_integration()
mgr = mod.FileWatcherManager()
mgr.running = True
mgr.observer = MagicMock()
mgr.stop()
assert mgr.is_running() is False
class TestFileWatcherManagerGetStats:
"""Tests for FileWatcherManager.get_stats() instance method."""
def test_default_stats(self):
"""get_stats() on fresh instance should show defaults."""
mod, _, _, _ = _import_file_watcher_integration()
mgr = mod.FileWatcherManager()
stats = mgr.get_stats()
assert stats["running"] is False
assert stats["branches_watched"] == 0
assert stats["branch_names"] == []
assert "watchdog_available" in stats
def test_stats_with_branches(self):
"""get_stats() should list watched branches."""
mod, _, _, _ = _import_file_watcher_integration()
mgr = mod.FileWatcherManager()
mgr.branch_paths = [
("SEEDGO", Path("/seedgo")),
("DRONE", Path("/drone")),
]
mgr.running = True
stats = mgr.get_stats()
assert stats["running"] is True
assert stats["branches_watched"] == 2
assert "SEEDGO" in stats["branch_names"]
assert "DRONE" in stats["branch_names"]
# =============================================
# INTERACTIVE FILTER TESTS
# =============================================
def _import_interactive_filter():
"""Import interactive_filter with fresh module state."""
mod_name = "aipass.prax.apps.handlers.monitoring.interactive_filter"
sys.modules.pop(mod_name, None)
return importlib.import_module(mod_name)
class TestParseCommand:
"""Tests for parse_command()."""
def test_simple_command(self):
"""Should parse single-word command."""
mod = _import_interactive_filter()
cmd, args = mod.parse_command("status")
assert cmd == "status"
assert args == []
def test_command_with_args(self):
"""Should parse command with arguments."""
mod = _import_interactive_filter()
cmd, args = mod.parse_command("watch PRAX CLI")
assert cmd == "watch"
assert args == ["PRAX", "CLI"]
def test_empty_string_returns_none(self):
"""Empty input should return (None, [])."""
mod = _import_interactive_filter()
cmd, args = mod.parse_command("")
assert cmd is None
assert args == []
def test_whitespace_only_returns_none(self):
"""Whitespace-only input should return (None, [])."""
mod = _import_interactive_filter()
cmd, args = mod.parse_command(" ")
assert cmd is None
assert args == []
def test_exit_normalized_to_quit(self):
"""'exit' should be normalized to 'quit'."""
mod = _import_interactive_filter()
cmd, _ = mod.parse_command("exit")
assert cmd == "quit"
def test_q_normalized_to_quit(self):
"""'q' should be normalized to 'quit'."""
mod = _import_interactive_filter()
cmd, _ = mod.parse_command("q")
assert cmd == "quit"
def test_uppercase_normalized(self):
"""Commands should be lowercased."""
mod = _import_interactive_filter()
cmd, _ = mod.parse_command("STATUS")
assert cmd == "status"
def test_leading_trailing_whitespace_stripped(self):
"""Leading and trailing whitespace should be stripped."""
mod = _import_interactive_filter()
cmd, args = mod.parse_command(" help ")
assert cmd == "help"
assert args == []
def test_logs_operation(self):
"""Should call json_handler.log_operation."""
mod = _import_interactive_filter()
mod.parse_command("status")
mod.json_handler.log_operation.assert_called()
class TestGetHelpText:
"""Tests for get_help_text()."""
def test_returns_string(self):
"""get_help_text() should return a string."""
mod = _import_interactive_filter()
result = mod.get_help_text()
assert isinstance(result, str)
def test_contains_key_commands(self):
"""Help text should mention available commands."""
mod = _import_interactive_filter()
result = mod.get_help_text()
assert "status" in result
assert "help" in result
assert "quit" in result
def test_not_empty(self):
"""Help text should not be empty."""
mod = _import_interactive_filter()
result = mod.get_help_text()
assert len(result.strip()) > 0
# =============================================
# UNIFIED STREAM TESTS
# =============================================
def _import_unified_stream():
"""Import unified_stream with fresh module state."""
mod_name = "aipass.prax.apps.handlers.monitoring.unified_stream"
sys.modules.pop(mod_name, None)
return importlib.import_module(mod_name)
class TestPrintEvent:
"""Tests for print_event()."""
def test_prints_to_console(self):
"""print_event() should call console.print."""
mod = _import_unified_stream()
mod.print_event("file", "PRAX", "file.py modified")
mod.console.print.assert_called()
def test_includes_branch_name(self):
"""Output should include the branch name."""
mod = _import_unified_stream()
mod.print_event("log", "SEEDGO", "audit complete")
call_args = mod.console.print.call_args[0][0]
assert "SEEDGO" in call_args
def test_includes_message(self):
"""Output should include the event message."""
mod = _import_unified_stream()
mod.print_event("system", "CLI", "started successfully")
call_args = mod.console.print.call_args[0][0]
assert "started successfully" in call_args
def test_with_pid(self):
"""Output should include PID when provided."""
mod = _import_unified_stream()
mod.print_event("file", "PRAX", "changed", pid=12345)
call_args = mod.console.print.call_args[0][0]
assert "12345" in call_args
def test_error_level_coloring(self):
"""Error-level messages should use red color."""
mod = _import_unified_stream()
mod.print_event("log", "FLOW", "something broke", level="error")
call_args = mod.console.print.call_args[0][0]
assert "red" in call_args
def test_logs_operation(self):
"""print_event should log via json_handler."""
mod = _import_unified_stream()
mod.print_event("file", "PRAX", "test message")
mod.json_handler.log_operation.assert_called_with(
"stream_output",
{"event_type": "file", "branch": "PRAX", "level": "info"},
)
def test_branch_color_lookup(self):
"""Should use branch-specific color from BRANCH_COLORS."""
mod = _import_unified_stream()
mod.print_event("file", "SEEDGO", "test")
call_args = mod.console.print.call_args[0][0]
assert "green" in call_args # SEEDGO -> green
def test_unknown_branch_uses_white(self):
"""Unknown branches should use white as default color."""
mod = _import_unified_stream()
mod.print_event("file", "XYZUNKNOWN", "test")
call_args = mod.console.print.call_args[0][0]
assert "white" in call_args
class TestPrintCommandSeparator:
"""Tests for print_command_separator()."""
def test_prints_separator(self):
"""print_command_separator should call console.print multiple times."""
mod = _import_unified_stream()
mod.print_command_separator("PRAX", "seedgo audit")
assert mod.console.print.call_count >= 3 # blank line + header + separator
def test_includes_command(self):
"""Output should include the command text."""
mod = _import_unified_stream()
mod.print_command_separator("CLI", "deploy --force")
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert "deploy --force" in combined
def test_with_caller(self):
"""Output should include caller when provided."""
mod = _import_unified_stream()
mod.print_command_separator("SEEDGO", "audit PRAX", caller="DRONE")
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert "DRONE" in combined
def test_with_target(self):
"""Output should include target when provided."""
mod = _import_unified_stream()
mod.print_command_separator("SEEDGO", "audit", target="FLOW")
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert "FLOW" in combined
def test_without_caller_or_target(self):
"""Should work without caller or target (no context line crash)."""
mod = _import_unified_stream()
# Should not raise
mod.print_command_separator("PRAX", "status")
assert mod.console.print.call_count >= 3
class TestPrintStatus:
"""Tests for print_status()."""
def test_prints_status(self):
"""print_status should call console.print with status info."""
mod = _import_unified_stream()
mod.print_status(["PRAX", "CLI"], verbosity=1)
assert mod.console.print.called
def test_includes_branch_names(self):
"""Status output should list watched branches."""
mod = _import_unified_stream()
mod.print_status(["SEEDGO", "FLOW"], verbosity=0)
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert "SEEDGO" in combined
assert "FLOW" in combined
def test_empty_branches_shows_all(self):
"""Empty branch list should show 'All branches'."""
mod = _import_unified_stream()
mod.print_status([], verbosity=0)
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert "All branches" in combined
def test_includes_verbosity(self):
"""Status output should include verbosity level."""
mod = _import_unified_stream()
mod.print_status(["PRAX"], verbosity=2)
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert "2" in combined
def test_with_filters(self):
"""Status output should show filter details when provided."""
mod = _import_unified_stream()
filters = {
"file_types": [".py", ".json"],
"log_levels": ["error", "warning"],
"exclude_patterns": ["__pycache__"],
}
mod.print_status(["PRAX"], verbosity=1, filters=filters)
calls = [str(c) for c in mod.console.print.call_args_list]
combined = " ".join(calls)
assert ".py" in combined
assert "error" in combined
assert "__pycache__" in combined
def test_without_filters(self):
"""Should work without filters (no crash)."""
mod = _import_unified_stream()
# Should not raise
mod.print_status(["PRAX"], verbosity=0, filters=None)
assert mod.console.print.called
+880
View File
@@ -16,8 +16,11 @@ to ensure the mocked dependencies are in place.
import importlib
import json
import sys
import types
from pathlib import Path
import pytest
MODULE_PATH = "aipass.prax.apps.handlers.dashboard.operations"
@@ -342,3 +345,880 @@ class TestCalculateQuickStatusStandalone:
result = ops._calculate_quick_status_standalone(sections)
assert result["new_mail"] == 7
assert result["action_required"] is True
# =============================================
# create_fresh_dashboard (operations.py)
# =============================================
class TestCreateFreshDashboard:
"""Tests for create_fresh_dashboard — creates dashboard from template or hardcoded fallback."""
def test_fallback_hardcoded_when_no_template_file(self, tmp_path: Path) -> None:
"""When template file does not exist, returns hardcoded dashboard."""
ops = _load_ops()
fake_prax = tmp_path / "prax"
fake_prax.mkdir()
original = ops._PRAX_ROOT
ops._PRAX_ROOT = fake_prax
try:
branch_dir = tmp_path / "mybranch"
branch_dir.mkdir()
result = ops.create_fresh_dashboard(branch_dir)
assert result["branch"] == "MYBRANCH"
assert "last_updated" in result
assert result["last_updated"] != ""
assert "_warning" in result
assert "sections" in result
assert "ai_mail" in result["sections"]
assert "flow" in result["sections"]
assert "memory" in result["sections"]
assert "commons_activity" in result["sections"]
assert result["quick_status"]["action_required"] is False
finally:
ops._PRAX_ROOT = original
def test_loads_from_template_file(self, tmp_path: Path) -> None:
"""When template file exists, uses it and replaces placeholders."""
ops = _load_ops()
fake_prax = tmp_path / "prax"
templates_dir = fake_prax / "templates"
templates_dir.mkdir(parents=True)
template_data = {
"_warning": "AUTO-GENERATED",
"branch": "{{BRANCHNAME}}",
"last_updated": "",
"sections": {
"ai_mail": {"managed_by": "ai_mail", "new": 0},
},
"quick_status": {"action_required": False},
}
(templates_dir / "DASHBOARD.template.json").write_text(json.dumps(template_data), encoding="utf-8")
original = ops._PRAX_ROOT
ops._PRAX_ROOT = fake_prax
try:
branch_dir = tmp_path / "flow"
branch_dir.mkdir()
result = ops.create_fresh_dashboard(branch_dir)
assert result["branch"] == "FLOW"
assert result["last_updated"] != ""
assert result["sections"]["ai_mail"]["new"] == 0
finally:
ops._PRAX_ROOT = original
def test_falls_back_on_corrupted_template(self, tmp_path: Path) -> None:
"""If template JSON is invalid, falls back to hardcoded structure."""
ops = _load_ops()
fake_prax = tmp_path / "prax"
templates_dir = fake_prax / "templates"
templates_dir.mkdir(parents=True)
(templates_dir / "DASHBOARD.template.json").write_text("{bad json!!", encoding="utf-8")
original = ops._PRAX_ROOT
ops._PRAX_ROOT = fake_prax
try:
branch_dir = tmp_path / "broken"
branch_dir.mkdir()
result = ops.create_fresh_dashboard(branch_dir)
# Should still return a valid hardcoded dashboard
assert result["branch"] == "BROKEN"
assert "sections" in result
assert "_warning" in result
finally:
ops._PRAX_ROOT = original
# =============================================
# update_section (operations.py — legacy interface)
# =============================================
class TestUpdateSectionLegacy:
"""Tests for update_section — legacy interface with template and status func."""
def test_updates_section_and_calls_status_func(self, tmp_path: Path) -> None:
"""Section is written and calculate_status_func is invoked with live data."""
ops = _load_ops()
branch_dir = tmp_path / "testbranch"
branch_dir.mkdir()
template = {
"branch": "",
"last_updated": "",
"sections": {"ai_mail": {"new": 0}},
}
status_called_with: dict[str, object] = {}
def mock_status(sections: dict[str, object]) -> dict[str, object]:
status_called_with.update(sections)
return {"action_required": True, "summary": "test"}
result = ops.update_section(branch_dir, "ai_mail", {"new": 5}, template, mock_status)
assert result is True
# Verify status function was called with sections containing our data
assert "ai_mail" in status_called_with
assert status_called_with["ai_mail"]["new"] == 5 # type: ignore[union-attr]
# Verify the file was written
data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8"))
assert data["sections"]["ai_mail"]["new"] == 5
assert data["quick_status"]["action_required"] is True
def test_creates_sections_dict_if_missing(self, tmp_path: Path) -> None:
ops = _load_ops()
branch_dir = tmp_path / "nosections"
branch_dir.mkdir()
# Pre-populate dashboard without sections key
(branch_dir / "DASHBOARD.local.json").write_text(
json.dumps({"branch": "NOSECTIONS", "last_updated": ""}),
encoding="utf-8",
)
template = {
"branch": "",
"last_updated": "",
"sections": {},
}
result = ops.update_section(
branch_dir,
"flow",
{"active_plans": 2},
template,
lambda s: {"action_required": False},
)
assert result is True
data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8"))
assert data["sections"]["flow"]["active_plans"] == 2
# =============================================
# refresh_all_dashboards (refresh.py)
# =============================================
REFRESH_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.refresh"
def _load_refresh() -> types.ModuleType:
"""Import (or reimport) the refresh module under active mocks."""
sys.modules.pop(REFRESH_MODULE_PATH, None)
# Also ensure dependent modules are reimported
sys.modules.pop("aipass.prax.apps.handlers.dashboard.operations", None)
import aipass.prax.apps.handlers.dashboard.refresh as mod
importlib.reload(mod)
return mod
class TestRefreshAllDashboards:
"""Tests for refresh_all_dashboards — orchestrates full refresh from centrals."""
def test_returns_success_when_all_branches_updated(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_refresh()
branch1 = tmp_path / "branch1"
branch1.mkdir()
branch2 = tmp_path / "branch2"
branch2.mkdir()
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
monkeypatch.setattr(mod, "_load_branch_paths", lambda: [branch1, branch2])
monkeypatch.setattr(
mod,
"create_fresh_dashboard",
lambda bp: {
"branch": bp.name.upper(),
"sections": {},
"quick_status": {},
},
)
result = mod.refresh_all_dashboards()
assert result["status"] == "success"
assert result["branches_updated"] == 2
assert result["branches_failed"] == 0
assert result["errors"] == []
def test_returns_error_when_branch_paths_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_refresh()
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
def _raise() -> list[Path]:
raise RuntimeError("registry gone")
monkeypatch.setattr(mod, "_load_branch_paths", _raise)
result = mod.refresh_all_dashboards()
assert result["status"] == "error"
assert result["branches_updated"] == 0
assert len(result["errors"]) == 1
assert "registry gone" in result["errors"][0]
def test_partial_status_on_mixed_success_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_refresh()
good_branch = tmp_path / "good"
good_branch.mkdir()
bad_branch = tmp_path / "bad"
bad_branch.mkdir()
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
monkeypatch.setattr(mod, "_load_branch_paths", lambda: [good_branch, bad_branch])
def flaky_create(bp: Path) -> dict[str, object]:
if bp.name == "bad":
raise RuntimeError("simulated failure")
return {
"branch": bp.name.upper(),
"sections": {},
"quick_status": {},
}
monkeypatch.setattr(mod, "create_fresh_dashboard", flaky_create)
result = mod.refresh_all_dashboards()
assert result["status"] == "partial"
assert result["branches_updated"] == 1
assert result["branches_failed"] == 1
# =============================================
# refresh_single_dashboard (refresh.py)
# =============================================
class TestRefreshSingleDashboard:
"""Tests for refresh_single_dashboard — refreshes one branch."""
def test_returns_success_for_valid_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_refresh()
branch_dir = tmp_path / "flow"
branch_dir.mkdir()
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
monkeypatch.setattr(
mod,
"create_fresh_dashboard",
lambda bp: {
"branch": bp.name.upper(),
"sections": {},
"quick_status": {},
},
)
result = mod.refresh_single_dashboard(branch_dir)
assert result["status"] == "success"
assert result["branch"] == "FLOW"
def test_returns_error_on_exception(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_refresh()
branch_dir = tmp_path / "failing"
branch_dir.mkdir()
monkeypatch.setattr(mod, "read_all_centrals", lambda: {})
def _raise(bp: Path) -> dict[str, object]:
raise RuntimeError("boom")
monkeypatch.setattr(mod, "create_fresh_dashboard", _raise)
result = mod.refresh_single_dashboard(branch_dir)
assert result["status"] == "error"
assert result["branch"] == "FAILING"
assert "boom" in result["error"]
# =============================================
# get_branch_paths (status.py)
# =============================================
STATUS_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.status"
def _load_status() -> types.ModuleType:
"""Import (or reimport) the status module under active mocks."""
sys.modules.pop(STATUS_MODULE_PATH, None)
import aipass.prax.apps.handlers.dashboard.status as mod
importlib.reload(mod)
return mod
class TestGetBranchPaths:
"""Tests for get_branch_paths — reads registry and returns branch paths."""
def test_returns_paths_from_registry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
registry_data = {
"branches": [
{"name": "flow", "path": str(tmp_path / "flow")},
{"name": "ai_mail", "path": str(tmp_path / "ai_mail")},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
result = mod.get_branch_paths()
assert len(result) == 2
assert all(isinstance(p, Path) for p in result)
def test_raises_when_registry_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
monkeypatch.setattr(mod, "AIPASS_REGISTRY", tmp_path / "nonexistent_registry.json")
with pytest.raises(FileNotFoundError):
mod.get_branch_paths()
def test_handles_relative_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
registry_data = {
"branches": [
{"name": "flow", "path": "src/aipass/flow"},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
result = mod.get_branch_paths()
assert len(result) == 1
assert result[0] == tmp_path / "src" / "aipass" / "flow"
# =============================================
# resolve_branch_path (status.py)
# =============================================
class TestResolveBranchPath:
"""Tests for resolve_branch_path — resolves @branch ref to filesystem path."""
def test_resolves_existing_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
branch_dir = tmp_path / "flow"
branch_dir.mkdir()
registry_data = {
"branches": [
{"name": "flow", "path": str(branch_dir)},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
result = mod.resolve_branch_path("@flow")
assert result == branch_dir
def test_strips_at_sign_and_is_case_insensitive(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
branch_dir = tmp_path / "vera"
branch_dir.mkdir()
registry_data = {
"branches": [
{"name": "VERA", "path": str(branch_dir)},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
result = mod.resolve_branch_path("@vera")
assert result == branch_dir
def test_raises_when_branch_not_in_registry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
registry_data: dict[str, list[object]] = {"branches": []}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
with pytest.raises(FileNotFoundError, match="not found in registry"):
mod.resolve_branch_path("@nonexistent")
def test_raises_when_path_does_not_exist(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
registry_data = {
"branches": [
{"name": "ghost", "path": str(tmp_path / "ghost")},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
with pytest.raises(FileNotFoundError, match="does not exist"):
mod.resolve_branch_path("@ghost")
def test_raises_when_registry_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_status()
monkeypatch.setattr(mod, "AIPASS_REGISTRY", tmp_path / "nonexistent_registry.json")
with pytest.raises(FileNotFoundError, match="AIPASS_REGISTRY"):
mod.resolve_branch_path("@flow")
# =============================================
# diff_dashboard_template (template_differ.py)
# =============================================
DIFFER_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.template_differ"
def _load_differ() -> types.ModuleType:
"""Import (or reimport) the template_differ module under active mocks."""
sys.modules.pop(DIFFER_MODULE_PATH, None)
import aipass.prax.apps.handlers.dashboard.template_differ as mod
importlib.reload(mod)
return mod
class TestDiffDashboardTemplate:
"""Tests for diff_dashboard_template — compares template vs branch dashboards."""
def _make_template(self) -> dict[str, object]:
return {
"_warning": "AUTO-GENERATED",
"branch": "{{BRANCHNAME}}",
"sections": {
"ai_mail": {"managed_by": "ai_mail", "new": 0, "last_updated": ""},
"flow": {"managed_by": "flow", "active_plans": 0, "last_updated": ""},
"memory": {"managed_by": "memory", "last_updated": ""},
"commons_activity": {"managed_by": "the_commons", "last_updated": ""},
},
"quick_status": {
"new_mail": 0,
"opened_mail": 0,
"active_plans": 0,
"commons_mentions": 0,
"action_required": False,
"summary": "",
},
}
def test_returns_error_when_template_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_differ()
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "nofile.json")
result = mod.diff_dashboard_template()
assert "error" in result
assert "not found" in result["error"]
def test_reports_up_to_date_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_differ()
# Create template file
template = self._make_template()
template_file = tmp_path / "template.json"
template_file.write_text(json.dumps(template), encoding="utf-8")
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
# Create registry
branch_dir = tmp_path / "flow"
branch_dir.mkdir()
registry_data = {
"branches": [
{"name": "FLOW", "path": str(branch_dir), "status": "active"},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
# Create a dashboard that is up to date
dashboard = {
"_warning": "AUTO-GENERATED",
"branch": "FLOW",
"sections": {
"ai_mail": {"managed_by": "ai_mail", "new": 0, "last_updated": "2026-01-01"},
"flow": {"managed_by": "flow", "active_plans": 0, "last_updated": "2026-01-01"},
"memory": {"managed_by": "memory", "last_updated": "2026-01-01"},
"commons_activity": {"managed_by": "the_commons", "last_updated": "2026-01-01"},
},
"quick_status": {
"new_mail": 0,
"opened_mail": 0,
"active_plans": 0,
"commons_mentions": 0,
"action_required": False,
"summary": "",
},
}
(branch_dir / "DASHBOARD.local.json").write_text(json.dumps(dashboard), encoding="utf-8")
result = mod.diff_dashboard_template()
assert result["summary"]["up_to_date"] == 1
assert result["summary"]["needs_update"] == 0
def test_reports_missing_dashboard(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_differ()
template = self._make_template()
template_file = tmp_path / "template.json"
template_file.write_text(json.dumps(template), encoding="utf-8")
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
branch_dir = tmp_path / "nobranch"
branch_dir.mkdir()
registry_data = {
"branches": [
{"name": "NOBRANCH", "path": str(branch_dir), "status": "active"},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
result = mod.diff_dashboard_template()
assert result["summary"]["missing"] == 1
def test_detects_deprecated_sections(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_differ()
template = self._make_template()
template_file = tmp_path / "template.json"
template_file.write_text(json.dumps(template), encoding="utf-8")
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
branch_dir = tmp_path / "oldbranch"
branch_dir.mkdir()
registry_data = {
"branches": [
{"name": "OLDBRANCH", "path": str(branch_dir), "status": "active"},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
# Dashboard with deprecated section
dashboard = {
"_warning": "AUTO-GENERATED",
"branch": "OLDBRANCH",
"sections": {
"ai_mail": {"new": 0, "last_updated": ""},
"flow": {"active_plans": 0, "last_updated": ""},
"memory": {"last_updated": ""},
"commons_activity": {"last_updated": ""},
"bulletin_board": {"posts": 0, "last_updated": ""},
},
"quick_status": {
"new_mail": 0,
"opened_mail": 0,
"active_plans": 0,
"commons_mentions": 0,
"action_required": False,
"summary": "",
},
}
(branch_dir / "DASHBOARD.local.json").write_text(json.dumps(dashboard), encoding="utf-8")
result = mod.diff_dashboard_template()
assert result["summary"]["needs_update"] == 1
branch_diff = result["branches"][0]
assert any("bulletin_board" in r for r in branch_diff["removals"])
def test_filters_to_single_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_differ()
template = self._make_template()
template_file = tmp_path / "template.json"
template_file.write_text(json.dumps(template), encoding="utf-8")
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
branch1 = tmp_path / "flow"
branch1.mkdir()
branch2 = tmp_path / "ai_mail"
branch2.mkdir()
registry_data = {
"branches": [
{"name": "FLOW", "path": str(branch1), "status": "active"},
{"name": "AI_MAIL", "path": str(branch2), "status": "active"},
]
}
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps(registry_data), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
result = mod.diff_dashboard_template(branch_name="FLOW")
assert len(result["branches"]) == 1
assert result["branches"][0]["branch"] == "FLOW"
# =============================================
# push_dashboard_template (template_pusher.py)
# =============================================
PUSHER_MODULE_PATH = "aipass.prax.apps.handlers.dashboard.template_pusher"
def _load_pusher() -> types.ModuleType:
"""Import (or reimport) the template_pusher module under active mocks."""
sys.modules.pop(PUSHER_MODULE_PATH, None)
import aipass.prax.apps.handlers.dashboard.template_pusher as mod
importlib.reload(mod)
return mod
class TestPushDashboardTemplate:
"""Tests for push_dashboard_template — pushes template to all branches."""
def _setup_template_and_registry(
self,
tmp_path: Path,
mod: types.ModuleType,
monkeypatch: pytest.MonkeyPatch,
branches: list[str],
) -> dict[str, object]:
"""Helper: create template file, registry, and branch dirs."""
template = {
"_warning": "AUTO-GENERATED",
"branch": "{{BRANCHNAME}}",
"sections": {
"ai_mail": {"managed_by": "ai_mail", "new": 0, "opened": 0, "total": 0, "last_updated": ""},
"flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""},
"memory": {"managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": ""},
"commons_activity": {
"managed_by": "the_commons",
"mentions": 0,
"new_posts_since_last_visit": 0,
"new_comments_since_last_visit": 0,
"last_updated": "",
},
},
"quick_status": {},
}
template_file = tmp_path / "template.json"
template_file.write_text(json.dumps(template), encoding="utf-8")
monkeypatch.setattr(mod, "TEMPLATE_FILE", template_file)
version_file = tmp_path / ".dashboard_version.json"
monkeypatch.setattr(mod, "VERSION_FILE", version_file)
branch_entries = []
for name in branches:
d = tmp_path / name
d.mkdir(exist_ok=True)
branch_entries.append({"name": name.upper(), "path": str(d), "status": "active"})
registry_file = tmp_path / "AIPASS_REGISTRY.json"
registry_file.write_text(json.dumps({"branches": branch_entries}), encoding="utf-8")
monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry_file)
monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path)
return template
def test_creates_dashboards_for_branches_without_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_pusher()
self._setup_template_and_registry(tmp_path, mod, monkeypatch, ["flow", "ai_mail"])
result = mod.push_dashboard_template(dry_run=False)
assert result["success"] is True
assert result["branches_created"] == 2
assert (tmp_path / "flow" / "DASHBOARD.local.json").exists()
assert (tmp_path / "ai_mail" / "DASHBOARD.local.json").exists()
def test_dry_run_does_not_write_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_pusher()
self._setup_template_and_registry(tmp_path, mod, monkeypatch, ["flow"])
result = mod.push_dashboard_template(dry_run=True)
assert result["dry_run"] is True
assert result["branches_created"] == 1
# File should NOT be created in dry run
assert not (tmp_path / "flow" / "DASHBOARD.local.json").exists()
def test_updates_existing_dashboard_with_structural_changes(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mod = _load_pusher()
self._setup_template_and_registry(tmp_path, mod, monkeypatch, ["flow"])
# Pre-create a dashboard with a deprecated section
existing = {
"_warning": "OLD WARNING",
"branch": "FLOW",
"sections": {
"ai_mail": {"managed_by": "ai_mail", "new": 3, "last_updated": "2026-01-01"},
"flow": {"managed_by": "flow", "active_plans": 2, "last_updated": "2026-01-01"},
"memory": {"managed_by": "memory", "last_updated": "2026-01-01"},
"commons_activity": {"managed_by": "the_commons", "last_updated": "2026-01-01"},
"bulletin_board": {"posts": 5},
},
"quick_status": {"pending_bulletins": 3},
}
(tmp_path / "flow" / "DASHBOARD.local.json").write_text(json.dumps(existing), encoding="utf-8")
result = mod.push_dashboard_template(dry_run=False)
assert result["branches_updated"] == 1
assert result["branches_created"] == 0
data = json.loads((tmp_path / "flow" / "DASHBOARD.local.json").read_text(encoding="utf-8"))
# Deprecated section removed
assert "bulletin_board" not in data["sections"]
# Deprecated quick_status key removed
assert "pending_bulletins" not in data.get("quick_status", {})
# Warning header updated
assert data["_warning"] == "AUTO-GENERATED"
# Existing data preserved
assert data["sections"]["ai_mail"]["new"] == 3
def test_returns_error_when_template_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_pusher()
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "no_template.json")
result = mod.push_dashboard_template()
assert result["success"] is False
assert len(result["errors"]) > 0
# =============================================
# get_template_status (template_pusher.py)
# =============================================
class TestGetTemplateStatus:
"""Tests for get_template_status — reads version file and template existence."""
def test_returns_status_when_version_file_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_pusher()
version_data = {
"version": "3.0.0",
"last_updated": "2026-03-01",
"updated_by": "prax",
"changes": ["added commons"],
"last_push": "2026-03-02 10:00:00",
"last_push_branches": ["FLOW", "AI_MAIL"],
}
version_file = tmp_path / ".dashboard_version.json"
version_file.write_text(json.dumps(version_data), encoding="utf-8")
monkeypatch.setattr(mod, "VERSION_FILE", version_file)
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "exists.json")
(tmp_path / "exists.json").write_text("{}", encoding="utf-8")
result = mod.get_template_status()
assert result["version"] == "3.0.0"
assert result["last_push"] == "2026-03-02 10:00:00"
assert result["last_push_branches"] == ["FLOW", "AI_MAIL"]
assert result["template_exists"] is True
def test_returns_defaults_when_no_version_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_pusher()
monkeypatch.setattr(mod, "VERSION_FILE", tmp_path / "nonexistent_version.json")
monkeypatch.setattr(mod, "TEMPLATE_FILE", tmp_path / "also_nonexistent.json")
result = mod.get_template_status()
assert result["version"] is None
assert result["last_push"] is None
assert result["last_push_branches"] == []
assert result["template_exists"] is False
# =============================================
# update_section (dashboard.py module wrapper)
# =============================================
DASHBOARD_MODULE_PATH = "aipass.prax.apps.modules.dashboard"
def _load_dashboard_module() -> types.ModuleType:
"""Import (or reimport) the dashboard module under active mocks."""
# Ensure handler dependencies are also cleared for fresh import
for mod_key in list(sys.modules.keys()):
if mod_key.startswith("aipass.prax.apps.handlers.dashboard"):
sys.modules.pop(mod_key, None)
sys.modules.pop(DASHBOARD_MODULE_PATH, None)
import aipass.prax.apps.modules.dashboard as mod
importlib.reload(mod)
return mod
class TestDashboardModuleUpdateSection:
"""Tests for dashboard.py module-level update_section wrapper."""
def test_delegates_to_handler_update_section(self, tmp_path: Path) -> None:
mod = _load_dashboard_module()
branch_dir = tmp_path / "wrapper_branch"
branch_dir.mkdir()
result = mod.update_section(branch_dir, "flow", {"active_plans": 3})
assert result is True
data = json.loads((branch_dir / "DASHBOARD.local.json").read_text(encoding="utf-8"))
assert data["sections"]["flow"]["active_plans"] == 3
def test_returns_false_on_handler_error(self, tmp_path: Path) -> None:
mod = _load_dashboard_module()
# A deeply nested nonexistent path should trigger an error
bad_path = tmp_path / "no" / "such" / "deep" / "branch"
result = mod.update_section(bad_path, "flow", {"active_plans": 1})
assert result is False
# =============================================
# print_status (dashboard.py)
# =============================================
class TestPrintStatus:
"""Tests for print_status -- CLI status display."""
def test_prints_branch_dashboard_status(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_dashboard_module()
branch1 = tmp_path / "flow"
branch1.mkdir()
(branch1 / "DASHBOARD.local.json").write_text("{}", encoding="utf-8")
branch2 = tmp_path / "ai_mail"
branch2.mkdir()
monkeypatch.setattr(mod, "get_branch_paths", lambda: [branch1, branch2])
# Should not raise
mod.print_status()
def test_handles_error_loading_branches(self, monkeypatch: pytest.MonkeyPatch) -> None:
mod = _load_dashboard_module()
def raise_error() -> list[Path]:
raise RuntimeError("registry not found")
monkeypatch.setattr(mod, "get_branch_paths", raise_error)
# Should not raise, just log/print error
mod.print_status()
# =============================================
# print_template (dashboard.py)
# =============================================
class TestPrintTemplate:
"""Tests for print_template -- CLI template display."""
def test_prints_template_without_error(self) -> None:
mod = _load_dashboard_module()
# Should not raise
mod.print_template()
@@ -1 +1,2 @@
# {{BRANCHNAME}} apps package
from . import handlers # noqa: F401
+1
View File
@@ -1 +1,2 @@
# Apps package - Branch application modules and handlers
from . import handlers # noqa: F401
@@ -0,0 +1,377 @@
# =================== AIPass ====================
# Name: test_error_detected.py
# Description: Tests for error_detected event handler with Medic v2 dispatch gating
# Version: 1.0.0
# Created: 2026-04-25
# Modified: 2026-04-25
# =============================================
"""Tests for error_detected event handler: set_send_email_callback, handle_error_detected, and fallback stubs."""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------------------
# Shared fixture: mocks config + json_handler, provides a registry-available
# environment by default. Individual tests override module-level helpers
# after importing.
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Mock config, json_handler, error_registry, and wake_branch before import."""
from aipass.trigger.apps.config import atomic_write_json
mock_config = MagicMock()
mock_config.TRIGGER_ROOT = tmp_path
mock_config.atomic_write_json = atomic_write_json
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
mock_json_handler = MagicMock()
mock_json_handler.log_operation = MagicMock(return_value=True)
json_pkg = MagicMock()
json_pkg.json_handler = mock_json_handler
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
monkeypatch.setitem(
sys.modules,
"aipass.trigger.apps.handlers.json.json_handler",
mock_json_handler,
)
# Provide a working error_registry mock so _REGISTRY_DISPATCH_AVAILABLE=True
mock_registry = MagicMock()
mock_registry.circuit_breaker_allows = MagicMock(return_value=True)
mock_registry.circuit_breaker_record_error = MagicMock()
mock_registry.should_dispatch = MagicMock(return_value=True)
mock_registry.record_dispatch = MagicMock()
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", mock_registry)
# Mock wake_branch import chain to prevent real imports
mock_wake = MagicMock()
mock_wake.wake_branch = MagicMock()
monkeypatch.setitem(sys.modules, "aipass.ai_mail", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers.dispatch", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers.dispatch.wake", mock_wake)
monkeypatch.delitem(
sys.modules,
"aipass.trigger.apps.handlers.events.error_detected",
raising=False,
)
def _import_module():
"""Import error_detected module fresh after mocking."""
import aipass.trigger.apps.handlers.events.error_detected as m
return m
def _setup_happy_path(mod: object) -> MagicMock:
"""Patch module internals for a successful dispatch and return the send_email mock."""
send_mock = MagicMock(return_value=True)
mod._is_medic_enabled = MagicMock(return_value=True) # type: ignore[attr-defined]
mod._is_branch_muted = MagicMock(return_value=False) # type: ignore[attr-defined]
mod._get_registered_emails = MagicMock(return_value={"@flow", "@spawn"}) # type: ignore[attr-defined]
mod._send_email = send_mock # type: ignore[attr-defined]
mod.circuit_breaker_allows = MagicMock(return_value=True) # type: ignore[attr-defined]
mod.registry_should_dispatch = MagicMock(return_value=True) # type: ignore[attr-defined]
mod.registry_record_dispatch = MagicMock() # type: ignore[attr-defined]
mod.circuit_breaker_record_error = MagicMock() # type: ignore[attr-defined]
mod._REGISTRY_DISPATCH_AVAILABLE = True # type: ignore[attr-defined]
return send_mock
# ---------------------------------------------------------------------------
# set_send_email_callback
# ---------------------------------------------------------------------------
class TestSetSendEmailCallback:
"""Tests for set_send_email_callback."""
def test_sets_callback(self) -> None:
"""Stores the callback as the module-level _send_email."""
mod = _import_module()
callback = MagicMock()
mod.set_send_email_callback(callback)
assert mod._send_email is callback
def test_overwrites_previous_callback(self) -> None:
"""Second call replaces the first callback."""
mod = _import_module()
first = MagicMock()
second = MagicMock()
mod.set_send_email_callback(first)
mod.set_send_email_callback(second)
assert mod._send_email is second
# ---------------------------------------------------------------------------
# handle_error_detected -- early-return gates
# ---------------------------------------------------------------------------
class TestHandleErrorDetectedGates:
"""Tests for early-return gates in handle_error_detected."""
def test_returns_early_missing_branch(self) -> None:
"""Does not dispatch when branch is None."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.handle_error_detected(branch=None, module="cfg", message="err", error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_missing_module(self) -> None:
"""Does not dispatch when module is None."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.handle_error_detected(branch="flow", module=None, message="err", error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_missing_message(self) -> None:
"""Does not dispatch when message is None."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.handle_error_detected(branch="flow", module="cfg", message=None, error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_missing_error_hash(self) -> None:
"""Does not dispatch when error_hash is None."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash=None, count=2)
send.assert_not_called()
def test_returns_early_medic_disabled(self) -> None:
"""Does not dispatch when medic is disabled."""
mod = _import_module()
send = _setup_happy_path(mod)
mod._is_medic_enabled = MagicMock(return_value=False) # type: ignore[attr-defined]
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_branch_muted(self) -> None:
"""Does not dispatch when branch is muted."""
mod = _import_module()
send = _setup_happy_path(mod)
mod._is_branch_muted = MagicMock(return_value=True) # type: ignore[attr-defined]
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_count_below_threshold(self) -> None:
"""Does not dispatch on first occurrence (count=1)."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=1)
send.assert_not_called()
def test_returns_early_send_email_is_none(self) -> None:
"""Does not dispatch when _send_email callback was never set."""
mod = _import_module()
_setup_happy_path(mod)
mod._send_email = None # type: ignore[attr-defined]
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
def test_returns_early_devpulse_recipient(self) -> None:
"""Does not dispatch to @devpulse (protected branch)."""
mod = _import_module()
send = _setup_happy_path(mod)
mod._get_registered_emails = MagicMock(return_value={"@devpulse"}) # type: ignore[attr-defined]
mod.handle_error_detected(branch="devpulse", module="cfg", message="err", error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_branch_not_in_registry(self) -> None:
"""Does not dispatch when branch email is not in the registry."""
mod = _import_module()
send = _setup_happy_path(mod)
mod._get_registered_emails = MagicMock(return_value={"@api", "@drone"}) # type: ignore[attr-defined]
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
send.assert_not_called()
def test_returns_early_circuit_breaker_open(self) -> None:
"""Does not dispatch when circuit breaker is open."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.circuit_breaker_allows = MagicMock(return_value=False) # type: ignore[attr-defined]
mod.handle_error_detected(
branch="flow",
module="cfg",
message="err",
error_hash="h1",
count=2,
fingerprint="abc123",
)
send.assert_not_called()
def test_returns_early_should_dispatch_false(self) -> None:
"""Does not dispatch when per-fingerprint backoff rejects."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.registry_should_dispatch = MagicMock(return_value=False) # type: ignore[attr-defined]
mod.handle_error_detected(
branch="flow",
module="cfg",
message="err",
error_hash="h1",
count=2,
fingerprint="abc123",
)
send.assert_not_called()
# ---------------------------------------------------------------------------
# handle_error_detected -- happy path
# ---------------------------------------------------------------------------
class TestHandleErrorDetectedHappyPath:
"""Tests for successful dispatch through handle_error_detected."""
def test_sends_email_with_correct_args(self) -> None:
"""Dispatches email to the correct recipient with auto_execute."""
mod = _import_module()
send = _setup_happy_path(mod)
mod.handle_error_detected(
branch="flow",
module="config",
message="NullPointerError",
error_hash="h1",
count=2,
fingerprint="fp123",
timestamp="2026-04-25 10:00:00",
)
send.assert_called_once()
kwargs = send.call_args[1]
assert kwargs["to_branch"] == "@flow"
assert kwargs["auto_execute"] is True
assert kwargs["reply_to"] == "@devpulse"
assert kwargs["from_branch"] == "@trigger"
def test_records_dispatch_after_send(self) -> None:
"""Calls registry_record_dispatch with the fingerprint after sending."""
mod = _import_module()
_setup_happy_path(mod)
mod.handle_error_detected(
branch="flow",
module="cfg",
message="err",
error_hash="h1",
count=2,
fingerprint="fp456",
)
mod.registry_record_dispatch.assert_called_once_with("fp456") # type: ignore[attr-defined]
def test_logs_dispatch_sent(self) -> None:
"""Logs dispatch_sent via json_handler after successful send."""
mod = _import_module()
_setup_happy_path(mod)
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_detected(
branch="flow",
module="cfg",
message="err",
error_hash="h1",
count=2,
fingerprint="fp789",
)
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"dispatch_sent", {"recipient": "@flow"}
)
def test_handles_send_exception_gracefully(self) -> None:
"""Does not raise when _send_email throws."""
mod = _import_module()
send = _setup_happy_path(mod)
send.side_effect = RuntimeError("SMTP down")
mod.handle_error_detected(
branch="flow",
module="cfg",
message="err",
error_hash="h1",
count=2,
fingerprint="fpX",
)
# ---------------------------------------------------------------------------
# Fallback stubs (when error_registry import fails)
# ---------------------------------------------------------------------------
class TestFallbackStubs:
"""Tests for fallback functions defined when error_registry is unavailable."""
@pytest.fixture(autouse=True)
def _force_registry_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Set error_registry to None so the ImportError fallback triggers."""
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", None)
monkeypatch.delitem(
sys.modules,
"aipass.trigger.apps.handlers.events.error_detected",
raising=False,
)
def test_registry_should_dispatch_returns_true(self) -> None:
"""Fallback always allows dispatch for any fingerprint."""
mod = _import_module()
assert mod.registry_should_dispatch("any-fingerprint") is True
def test_registry_record_dispatch_does_not_raise(self) -> None:
"""Fallback record_dispatch is a no-op."""
mod = _import_module()
mod.registry_record_dispatch("any-fingerprint")
def test_circuit_breaker_allows_returns_true(self) -> None:
"""Fallback circuit breaker always allows."""
mod = _import_module()
assert mod.circuit_breaker_allows() is True
def test_circuit_breaker_record_error_does_not_raise(self) -> None:
"""Fallback circuit_breaker_record_error is a no-op."""
mod = _import_module()
mod.circuit_breaker_record_error()
def test_registry_dispatch_available_is_false(self) -> None:
"""Module reports registry dispatch as unavailable."""
mod = _import_module()
assert mod._REGISTRY_DISPATCH_AVAILABLE is False
@@ -0,0 +1,441 @@
# =================== AIPass ====================
# Name: test_event_handlers.py
# Description: Tests for simple event handler functions
# Version: 1.0.0
# Created: 2026-04-25
# Modified: 2026-04-25
# =============================================
"""Tests for cli, error_logged, memory, memory_template_updated, warning_logged, and bulletin_created event handlers."""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
@pytest.fixture(autouse=True)
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Mock heavy infrastructure imports for all six handler modules."""
from aipass.trigger.apps.config import atomic_write_json
mock_config = MagicMock()
mock_config.TRIGGER_ROOT = tmp_path
mock_config.AIPASS_PKG_ROOT = tmp_path / "aipass"
mock_config.atomic_write_json = atomic_write_json
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
mock_json_handler = MagicMock()
mock_json_handler.log_operation = MagicMock(return_value=True)
json_pkg = MagicMock()
json_pkg.json_handler = mock_json_handler
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
monkeypatch.setitem(
sys.modules,
"aipass.trigger.apps.handlers.json.json_handler",
mock_json_handler,
)
for mod_name in (
"aipass.trigger.apps.handlers.events.cli",
"aipass.trigger.apps.handlers.events.error_logged",
"aipass.trigger.apps.handlers.events.memory",
"aipass.trigger.apps.handlers.events.memory_template_updated",
"aipass.trigger.apps.handlers.events.warning_logged",
"aipass.trigger.apps.handlers.events.bulletin_created",
):
monkeypatch.delitem(sys.modules, mod_name, raising=False)
def _import_cli():
"""Import cli handler module fresh after mocking."""
import aipass.trigger.apps.handlers.events.cli as m
return m
def _import_error_logged():
"""Import error_logged handler module fresh after mocking."""
import aipass.trigger.apps.handlers.events.error_logged as m
return m
def _import_memory():
"""Import memory handler module fresh after mocking."""
import aipass.trigger.apps.handlers.events.memory as m
return m
def _import_memory_template_updated():
"""Import memory_template_updated handler module fresh after mocking."""
import aipass.trigger.apps.handlers.events.memory_template_updated as m
return m
def _import_warning_logged():
"""Import warning_logged handler module fresh after mocking."""
import aipass.trigger.apps.handlers.events.warning_logged as m
return m
def _import_bulletin():
"""Import bulletin_created handler module fresh after mocking."""
import aipass.trigger.apps.handlers.events.bulletin_created as m
return m
# ---------------------------------------------------------------------------
# cli.py -- handle_cli_header_displayed
# ---------------------------------------------------------------------------
class TestHandleCliHeaderDisplayed:
"""Tests for handle_cli_header_displayed from cli.py."""
def test_calls_log_operation(self) -> None:
"""Logs cli_event via json_handler."""
mod = _import_cli()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_cli_header_displayed()
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"cli_event", {"success": True}
)
def test_accepts_arbitrary_kwargs(self) -> None:
"""Does not crash when extra kwargs are passed."""
mod = _import_cli()
mod.handle_cli_header_displayed(foo="bar", baz=42)
def test_returns_none(self) -> None:
"""Handler returns None (handlers must not return values)."""
mod = _import_cli()
result = mod.handle_cli_header_displayed()
assert result is None
# ---------------------------------------------------------------------------
# error_logged.py -- handle_error_logged
# ---------------------------------------------------------------------------
class TestHandleErrorLogged:
"""Tests for handle_error_logged from error_logged.py."""
def test_happy_path_logs_operation(self) -> None:
"""Logs error_logged_event with branch, module, and error_hash."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(branch="flow", message="kaboom", error_hash="abc123")
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"error_logged_event",
{"branch": "flow", "module": "unknown", "error_hash": "abc123"},
)
def test_returns_early_missing_branch(self) -> None:
"""Does not log when branch is None."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(branch=None, message="msg", error_hash="h")
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
def test_returns_early_missing_message(self) -> None:
"""Does not log when message is None."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(branch="flow", message=None, error_hash="h")
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
def test_returns_early_missing_error_hash(self) -> None:
"""Does not log when error_hash is None."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(branch="flow", message="msg", error_hash=None)
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
def test_returns_early_empty_branch(self) -> None:
"""Does not log when branch is empty string (falsy)."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(branch="", message="msg", error_hash="h")
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
def test_prefers_source_module_over_module_name(self) -> None:
"""Uses source_module when both source_module and module_name are given."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(
branch="api",
message="err",
error_hash="xyz",
source_module="config.py",
module_name="old_name.py",
)
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"error_logged_event",
{"branch": "api", "module": "config.py", "error_hash": "xyz"},
)
def test_falls_back_to_module_name(self) -> None:
"""Uses module_name when source_module is not provided."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(
branch="spawn",
message="err",
error_hash="h1",
module_name="fallback.py",
)
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"error_logged_event",
{"branch": "spawn", "module": "fallback.py", "error_hash": "h1"},
)
def test_falls_back_to_unknown(self) -> None:
"""Uses 'unknown' when neither source_module nor module_name given."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_error_logged(branch="drone", message="oops", error_hash="h2")
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"error_logged_event",
{"branch": "drone", "module": "unknown", "error_hash": "h2"},
)
def test_exception_does_not_raise(self) -> None:
"""Catches exception from log_operation without propagating."""
mod = _import_error_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.side_effect = RuntimeError("boom") # type: ignore[union-attr]
mod.handle_error_logged(branch="flow", message="msg", error_hash="h3")
json_handler.log_operation.side_effect = None # type: ignore[union-attr]
# ---------------------------------------------------------------------------
# memory.py -- handle_memory_saved
# ---------------------------------------------------------------------------
class TestHandleMemorySaved:
"""Tests for handle_memory_saved from memory.py."""
def test_calls_log_operation(self) -> None:
"""Logs memory_event via json_handler."""
mod = _import_memory()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_memory_saved()
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"memory_event", {"success": True}
)
def test_accepts_kwargs(self) -> None:
"""Does not crash when event data kwargs are passed."""
mod = _import_memory()
mod.handle_memory_saved(branch="flow", lines=150)
def test_returns_none(self) -> None:
"""Handler returns None."""
mod = _import_memory()
result = mod.handle_memory_saved()
assert result is None
# ---------------------------------------------------------------------------
# memory_template_updated.py -- handle_memory_template_updated
# ---------------------------------------------------------------------------
class TestHandleMemoryTemplateUpdated:
"""Tests for handle_memory_template_updated from memory_template_updated.py."""
def test_calls_log_operation(self) -> None:
"""Logs memory_template_event via json_handler."""
mod = _import_memory_template_updated()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_memory_template_updated()
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"memory_template_event", {"success": True}
)
def test_accepts_kwargs(self) -> None:
"""Does not crash when event data kwargs are passed."""
mod = _import_memory_template_updated()
mod.handle_memory_template_updated(template_name="local", updated_by="drone")
def test_returns_none(self) -> None:
"""Handler returns None."""
mod = _import_memory_template_updated()
result = mod.handle_memory_template_updated()
assert result is None
# ---------------------------------------------------------------------------
# warning_logged.py -- handle_warning_logged
# ---------------------------------------------------------------------------
class TestHandleWarningLogged:
"""Tests for handle_warning_logged from warning_logged.py."""
def test_calls_log_operation(self) -> None:
"""Logs warning_logged_event via json_handler."""
mod = _import_warning_logged()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_warning_logged()
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"warning_logged_event", {"success": True}
)
def test_accepts_all_named_params(self) -> None:
"""Accepts all documented event parameters without error."""
mod = _import_warning_logged()
mod.handle_warning_logged(
branch="flow",
message="disk almost full",
error_hash="w1",
timestamp="2026-04-25T12:00:00",
log_file="flow.log",
module_name="watcher",
level="warning",
)
def test_does_not_crash_with_none_params(self) -> None:
"""Handles None for every named parameter gracefully."""
mod = _import_warning_logged()
mod.handle_warning_logged(
branch=None,
message=None,
error_hash=None,
timestamp=None,
log_file=None,
module_name=None,
level=None,
)
def test_accepts_extra_kwargs(self) -> None:
"""Accepts unexpected kwargs via **kwargs."""
mod = _import_warning_logged()
mod.handle_warning_logged(extra_field="unexpected")
def test_returns_none(self) -> None:
"""Handler returns None."""
mod = _import_warning_logged()
result = mod.handle_warning_logged()
assert result is None
# ---------------------------------------------------------------------------
# bulletin_created.py -- handle_bulletin_created
# ---------------------------------------------------------------------------
class TestHandleBulletinCreated:
"""Tests for handle_bulletin_created from bulletin_created.py."""
def test_does_not_raise_when_files_missing(self) -> None:
"""Silently handles missing registry and bulletins files."""
mod = _import_bulletin()
mod.handle_bulletin_created()
def test_logs_operation_on_success(self) -> None:
"""Logs bulletin_event after successful propagation."""
mod = _import_bulletin()
mod._propagate_bulletins_to_branches = MagicMock()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_bulletin_created()
mod._propagate_bulletins_to_branches.assert_called_once()
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"bulletin_event", {"success": True}
)
def test_catches_propagation_exception(self) -> None:
"""Does not log operation when propagation raises."""
mod = _import_bulletin()
mod._propagate_bulletins_to_branches = MagicMock(side_effect=RuntimeError("propagation failed"))
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_bulletin_created()
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
def test_accepts_all_params(self) -> None:
"""Accepts all documented event parameters without error."""
mod = _import_bulletin()
mod._propagate_bulletins_to_branches = MagicMock()
mod.handle_bulletin_created(
_bulletin_id="b1",
_title="System update",
_message="Scheduled maintenance",
_priority="high",
_created_by="devpulse",
_timestamp="2026-04-25T10:00:00",
)
def test_returns_none(self) -> None:
"""Handler returns None."""
mod = _import_bulletin()
mod._propagate_bulletins_to_branches = MagicMock()
result = mod.handle_bulletin_created()
assert result is None
@@ -33,7 +33,10 @@ def json_handler(tmp_path, monkeypatch):
class TestDefaultFactory:
"""Tests for _get_default_template factory."""
def test_config_template_has_required_keys(self, json_handler):
"""Config template contains module_name, version, and config keys."""
result = json_handler._get_default_template("config", "test_mod")
assert isinstance(result, dict)
assert "module_name" in result
@@ -42,17 +45,20 @@ class TestDefaultFactory:
assert result["module_name"] == "test_mod"
def test_data_template_has_required_keys(self, json_handler):
"""Data template contains created and last_updated keys."""
result = json_handler._get_default_template("data", "test_mod")
assert isinstance(result, dict)
assert "created" in result
assert "last_updated" in result
def test_log_template_returns_list(self, json_handler):
"""Log template returns an empty list."""
result = json_handler._get_default_template("log", "test_mod")
assert isinstance(result, list)
assert len(result) == 0
def test_unknown_type_raises(self, json_handler):
"""Unknown json_type raises ValueError."""
with pytest.raises(ValueError, match="Unknown json_type"):
json_handler._get_default_template("bogus", "test_mod")
@@ -63,23 +69,31 @@ class TestDefaultFactory:
class TestValidate:
"""Tests for validate_json_structure."""
def test_valid_config(self, json_handler):
"""Valid config dict with all required keys passes validation."""
data = {"module_name": "x", "version": "1.0", "config": {}}
assert json_handler.validate_json_structure(data, "config") is True
def test_invalid_config_missing_key(self, json_handler):
"""Config dict missing required keys fails validation."""
assert json_handler.validate_json_structure({"module_name": "x"}, "config") is False
def test_config_non_dict(self, json_handler):
"""Non-dict input fails config validation."""
assert json_handler.validate_json_structure([], "config") is False
def test_valid_data(self, json_handler):
"""Data dict with created and last_updated passes validation."""
assert json_handler.validate_json_structure({"created": "x", "last_updated": "y"}, "data") is True
def test_valid_log(self, json_handler):
"""List passes log validation."""
assert json_handler.validate_json_structure([], "log") is True
def test_unknown_type(self, json_handler):
"""Unknown json_type returns False."""
assert json_handler.validate_json_structure({}, "bogus") is False
@@ -89,11 +103,15 @@ class TestValidate:
class TestGetPath:
"""Tests for get_json_path."""
def test_returns_path_object(self, json_handler):
"""Return type is a Path instance."""
result = json_handler.get_json_path("mymod", "config")
assert isinstance(result, Path)
def test_path_contains_module_and_type(self, json_handler):
"""Filename encodes module name and json type."""
result = json_handler.get_json_path("mymod", "data")
assert result.name == "mymod_data.json"
@@ -109,7 +127,10 @@ class TestGetPath:
class TestEnsureExists:
"""Tests for ensure_json_exists."""
def test_creates_config_file(self, json_handler, tmp_path):
"""Creates a config JSON file on disk."""
assert json_handler.ensure_json_exists("newmod", "config") is True
path = tmp_path / "newmod_config.json"
assert path.exists()
@@ -146,12 +167,16 @@ class TestEnsureExists:
class TestLoad:
"""Tests for load_json."""
def test_load_auto_creates_and_returns(self, json_handler):
"""Auto-creates missing file and returns default template."""
result = json_handler.load_json("loadtest", "config")
assert isinstance(result, dict)
assert result["module_name"] == "loadtest"
def test_load_log_returns_list(self, json_handler):
"""Log type returns a list."""
result = json_handler.load_json("loadtest", "log")
assert isinstance(result, list)
@@ -170,7 +195,10 @@ class TestLoad:
class TestSave:
"""Tests for save_json."""
def test_save_valid_data(self, json_handler, tmp_path):
"""Saves valid data dict to disk."""
json_handler.ensure_json_exists("smod", "data")
data = {"created": "2026-01-01", "last_updated": "2026-01-01", "extra": 42}
assert json_handler.save_json("smod", "data", data) is True
@@ -194,13 +222,17 @@ class TestSave:
class TestEnsureModule:
"""Tests for ensure_module_jsons."""
def test_creates_all_three(self, json_handler, tmp_path):
"""Creates config, data, and log JSON files."""
json_handler.ensure_module_jsons("trio")
assert (tmp_path / "trio_config.json").exists()
assert (tmp_path / "trio_data.json").exists()
assert (tmp_path / "trio_log.json").exists()
def test_returns_true(self, json_handler):
"""Returns True on success."""
assert json_handler.ensure_module_jsons("rt") is True
@@ -210,7 +242,10 @@ class TestEnsureModule:
class TestLogOperation:
"""Tests for log_operation."""
def test_log_operation_appends_entry(self, json_handler, tmp_path):
"""Appends a log entry with operation name."""
json_handler.log_operation("test_op", {"key": "val"}, module_name="logmod")
log = json.loads((tmp_path / "logmod_log.json").read_text())
assert len(log) >= 1
@@ -240,3 +275,75 @@ class TestLogOperation:
monkeypatch.setattr(mod, "TRIGGER_JSON_DIR", new_dir)
mod.ensure_json_exists("reimp", "config")
assert (new_dir / "reimp_config.json").exists()
# ---------------------------------------------------------------------------
# increment_counter
# ---------------------------------------------------------------------------
class TestIncrementCounter:
"""Tests for increment_counter."""
def test_creates_and_increments_new_counter(self, json_handler, tmp_path):
"""Counter starts at 0, gets incremented to 1."""
json_handler.increment_counter("incmod", "hits")
data = json.loads((tmp_path / "incmod_data.json").read_text(encoding="utf-8"))
assert data["hits"] == 1
def test_increments_existing_counter(self, json_handler, tmp_path):
"""Pre-set counter at 5, increment brings it to 6."""
json_handler.ensure_module_jsons("incmod2")
data = json_handler.load_json("incmod2", "data")
data["visits"] = 5
json_handler.save_json("incmod2", "data", data)
json_handler.increment_counter("incmod2", "visits")
reloaded = json.loads((tmp_path / "incmod2_data.json").read_text(encoding="utf-8"))
assert reloaded["visits"] == 6
def test_custom_amount(self, json_handler, tmp_path):
"""Increment by 10."""
json_handler.increment_counter("incmod3", "score", amount=10)
data = json.loads((tmp_path / "incmod3_data.json").read_text(encoding="utf-8"))
assert data["score"] == 10
def test_returns_true_on_success(self, json_handler):
"""Return value is True on success."""
result = json_handler.increment_counter("incmod4", "counter")
assert result is True
# ---------------------------------------------------------------------------
# update_data_metrics
# ---------------------------------------------------------------------------
class TestUpdateDataMetrics:
"""Tests for update_data_metrics."""
def test_sets_single_metric(self, json_handler, tmp_path):
"""Update one key."""
json_handler.update_data_metrics("metmod", uptime=99.5)
data = json.loads((tmp_path / "metmod_data.json").read_text(encoding="utf-8"))
assert data["uptime"] == 99.5
def test_sets_multiple_metrics(self, json_handler, tmp_path):
"""Update several keys at once."""
json_handler.update_data_metrics("metmod2", cpu=0.8, mem=512, ok=True)
data = json.loads((tmp_path / "metmod2_data.json").read_text(encoding="utf-8"))
assert data["cpu"] == 0.8
assert data["mem"] == 512
assert data["ok"] is True
def test_overwrites_existing(self, json_handler, tmp_path):
"""Set a key, then update it to a new value."""
json_handler.update_data_metrics("metmod3", version="1.0")
json_handler.update_data_metrics("metmod3", version="2.0")
data = json.loads((tmp_path / "metmod3_data.json").read_text(encoding="utf-8"))
assert data["version"] == "2.0"
def test_returns_true_on_success(self, json_handler):
"""Return value is True on success."""
result = json_handler.update_data_metrics("metmod4", status="ok")
assert result is True
@@ -0,0 +1,205 @@
# =================== AIPass ====================
# Name: test_memory_threshold_handler.py
# Description: Tests for memory_threshold_exceeded event handler
# Version: 1.0.0
# Created: 2026-04-25
# Modified: 2026-04-25
# =============================================
"""Tests for memory_threshold_exceeded event handler."""
import sys
import pytest
from unittest.mock import MagicMock
from pathlib import Path
@pytest.fixture(autouse=True)
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Mock heavy infrastructure imports before importing the handler module."""
from aipass.trigger.apps.config import atomic_write_json
mock_config = MagicMock()
mock_config.TRIGGER_ROOT = tmp_path
mock_config.atomic_write_json = atomic_write_json
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
mock_json_handler = MagicMock()
mock_json_handler.log_operation = MagicMock(return_value=True)
json_pkg = MagicMock()
json_pkg.json_handler = mock_json_handler
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
monkeypatch.setitem(
sys.modules,
"aipass.trigger.apps.handlers.json.json_handler",
mock_json_handler,
)
# Mock ai_mail chain so the handler can import deliver_email_to_branch
mock_email_send = MagicMock()
mock_email_send.deliver_email_to_branch = MagicMock()
monkeypatch.setitem(sys.modules, "aipass.ai_mail", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules", MagicMock())
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules.email_send", mock_email_send)
monkeypatch.delitem(
sys.modules,
"aipass.trigger.apps.handlers.events.memory_threshold_exceeded",
raising=False,
)
def _import_memory_threshold():
"""Import fresh after mocking."""
import aipass.trigger.apps.handlers.events.memory_threshold_exceeded as m
return m
class TestHandleMemoryThresholdExceeded:
"""Tests for handle_memory_threshold_exceeded."""
def test_returns_early_when_branch_missing(self) -> None:
"""None branch skips email delivery."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(branch=None, file_name="local.json", line_count=700)
deliver_email_to_branch.assert_not_called() # type: ignore[union-attr]
def test_returns_early_when_file_name_missing(self) -> None:
"""None file_name skips email delivery."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(branch="flow", file_name=None, line_count=700)
deliver_email_to_branch.assert_not_called() # type: ignore[union-attr]
def test_returns_early_when_line_count_is_none(self) -> None:
"""None line_count skips email delivery."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(branch="flow", file_name="local.json", line_count=None)
deliver_email_to_branch.assert_not_called() # type: ignore[union-attr]
def test_returns_early_when_ai_mail_import_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""No operation is logged when ai_mail import fails."""
# Setting the module to None causes ImportError on `from ... import`
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules.email_send", None)
mod = _import_memory_threshold()
mod.handle_memory_threshold_exceeded(branch="flow", file_name="local.json", line_count=700)
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
def test_happy_path_sends_email(self) -> None:
"""Sends email with correct target and email data on valid input."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(
branch="flow",
file_name="local.json",
line_count=700,
threshold=600,
timestamp="2026-04-25 12:00:00",
)
deliver_email_to_branch.assert_called_once() # type: ignore[union-attr]
target, email_data = deliver_email_to_branch.call_args[0] # type: ignore[union-attr]
assert target == "@flow"
assert email_data["to"] == "@flow"
assert email_data["from"] == "@trigger"
assert "local.json" in email_data["subject"]
assert "600" in email_data["subject"]
assert email_data["timestamp"] == "2026-04-25 12:00:00"
def test_uses_default_threshold_when_not_provided(self) -> None:
"""Falls back to default threshold of 600 when not explicitly given."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(branch="drone", file_name="observations.json", line_count=800)
deliver_email_to_branch.assert_called_once() # type: ignore[union-attr]
_, email_data = deliver_email_to_branch.call_args[0] # type: ignore[union-attr]
assert "600" in email_data["subject"]
def test_uses_default_timestamp_when_not_provided(self) -> None:
"""Generates a non-empty timestamp when none is supplied."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(branch="api", file_name="local.json", line_count=650)
deliver_email_to_branch.assert_called_once() # type: ignore[union-attr]
_, email_data = deliver_email_to_branch.call_args[0] # type: ignore[union-attr]
assert email_data["timestamp"] is not None
assert len(email_data["timestamp"]) > 0
def test_does_not_raise_on_deliver_exception(self) -> None:
"""Delivery failure is swallowed without propagating."""
mod = _import_memory_threshold()
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
deliver_email_to_branch.side_effect = RuntimeError( # type: ignore[union-attr]
"delivery failed"
)
mod.handle_memory_threshold_exceeded(
branch="flow",
file_name="local.json",
line_count=700,
threshold=600,
)
deliver_email_to_branch.side_effect = None # type: ignore[union-attr]
def test_logs_operation_on_success(self) -> None:
"""Logs memory_threshold_event via json_handler after successful send."""
mod = _import_memory_threshold()
from aipass.trigger.apps.handlers.json import json_handler
from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
deliver_email_to_branch.reset_mock() # type: ignore[union-attr]
mod.handle_memory_threshold_exceeded(
branch="system",
file_name="local.json",
line_count=900,
threshold=600,
)
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"memory_threshold_event", {"success": True}
)
@@ -0,0 +1,374 @@
# =================== AIPass ====================
# Name: test_plan_file_handler.py
# Description: Tests for plan_file event handlers (created, deleted, moved)
# Version: 1.0.0
# Created: 2026-04-25
# Modified: 2026-04-25
# =============================================
"""Tests for plan_file event handlers."""
import pytest
from unittest.mock import MagicMock
from pathlib import Path
@pytest.fixture(autouse=True)
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Mock heavy infrastructure imports before importing the handler module."""
import sys
from aipass.trigger.apps.config import atomic_write_json
mock_config = MagicMock()
mock_config.TRIGGER_ROOT = tmp_path
mock_config.AIPASS_PKG_ROOT = tmp_path / "aipass"
mock_config.atomic_write_json = atomic_write_json
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
mock_json_handler = MagicMock()
mock_json_handler.log_operation = MagicMock(return_value=True)
json_pkg = MagicMock()
json_pkg.json_handler = mock_json_handler
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
monkeypatch.setitem(
sys.modules,
"aipass.trigger.apps.handlers.json.json_handler",
mock_json_handler,
)
monkeypatch.delitem(
sys.modules,
"aipass.trigger.apps.handlers.events.plan_file",
raising=False,
)
def _import_plan_file():
"""Import fresh after mocking."""
import aipass.trigger.apps.handlers.events.plan_file as m
return m
class TestHandlePlanFileCreated:
"""Tests for handle_plan_file_created."""
def test_new_plan_added_to_registry(self, tmp_path: Path) -> None:
"""New FPLAN file adds entry with status open."""
mod = _import_plan_file()
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_created(path=plan_path)
mod._save_registry.assert_called_once() # type: ignore[union-attr]
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
assert "0042" in saved["plans"]
plan = saved["plans"]["0042"]
assert plan["status"] == "open"
assert plan["file_path"] == plan_path
assert plan["subject"] == "Auto-detected PLAN"
def test_existing_open_plan_is_noop(self, tmp_path: Path) -> None:
"""Existing open plan causes early return without saving."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {"0042": {"status": "open", "file_path": "/old/FPLAN-0042.md"}},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_created(path=plan_path)
mod._save_registry.assert_not_called() # type: ignore[union-attr]
def test_existing_closed_plan_updates_location(self, tmp_path: Path) -> None:
"""Closed plan gets location fields updated, status preserved."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {
"0042": {
"status": "closed",
"file_path": "/old/FPLAN-0042.md",
"closed_reason": "completed",
}
},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_created(path=plan_path)
mod._save_registry.assert_called_once() # type: ignore[union-attr]
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
plan = saved["plans"]["0042"]
assert plan["status"] == "closed"
assert plan["file_path"] == plan_path
assert plan["closed_reason"] == "completed"
assert "last_updated" in plan
def test_updates_next_number_when_needed(self, tmp_path: Path) -> None:
"""Next number advances past the new plan number."""
mod = _import_plan_file()
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_created(path=plan_path)
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
assert saved["next_number"] == 43
def test_does_not_lower_next_number(self, tmp_path: Path) -> None:
"""Next number stays at 100 when plan 42 is added."""
mod = _import_plan_file()
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 100})
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_created(path=plan_path)
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
assert saved["next_number"] == 100
def test_returns_early_for_non_plan_file(self, tmp_path: Path) -> None:
"""Non-FPLAN filenames cause immediate return."""
mod = _import_plan_file()
mod._load_registry = MagicMock()
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
non_plan_path = str(tmp_path / "README.md")
mod.handle_plan_file_created(path=non_plan_path)
mod._load_registry.assert_not_called() # type: ignore[union-attr]
mod._save_registry.assert_not_called() # type: ignore[union-attr]
def test_logs_operation_on_success(self, tmp_path: Path) -> None:
"""Calls json_handler.log_operation after adding new plan."""
mod = _import_plan_file()
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
plan_path = str(tmp_path / "FPLAN-0001.md")
mod.handle_plan_file_created(path=plan_path)
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"plan_event", {"success": True}
)
class TestHandlePlanFileDeleted:
"""Tests for handle_plan_file_deleted."""
def test_open_plan_removed_from_registry(self, tmp_path: Path) -> None:
"""Open plan is deleted entirely from the plans dict."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {"0042": {"status": "open", "file_path": "/some/FPLAN-0042.md"}},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_deleted(path=plan_path)
mod._save_registry.assert_called_once() # type: ignore[union-attr]
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
assert "0042" not in saved["plans"]
def test_closed_plan_marked_archived(self, tmp_path: Path) -> None:
"""Closed plan gets archived flag instead of being removed."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {
"0042": {
"status": "closed",
"file_path": "/some/FPLAN-0042.md",
}
},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_deleted(path=plan_path)
mod._save_registry.assert_called_once() # type: ignore[union-attr]
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
assert saved["plans"]["0042"]["archived"] is True
assert "archived_date" in saved["plans"]["0042"]
def test_processed_plan_marked_archived(self, tmp_path: Path) -> None:
"""Plan with processed=True gets archived flag."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {
"0042": {
"status": "open",
"processed": True,
"file_path": "/some/FPLAN-0042.md",
}
},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_deleted(path=plan_path)
mod._save_registry.assert_called_once() # type: ignore[union-attr]
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
assert saved["plans"]["0042"]["archived"] is True
def test_unknown_plan_no_crash(self, tmp_path: Path) -> None:
"""Plan not in registry causes no error."""
mod = _import_plan_file()
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
plan_path = str(tmp_path / "FPLAN-9999.md")
mod.handle_plan_file_deleted(path=plan_path)
mod._save_registry.assert_not_called() # type: ignore[union-attr]
def test_returns_early_for_non_plan_file(self, tmp_path: Path) -> None:
"""Non-FPLAN filenames cause immediate return."""
mod = _import_plan_file()
mod._load_registry = MagicMock()
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
mod.handle_plan_file_deleted(path=str(tmp_path / "random-notes.txt"))
mod._load_registry.assert_not_called() # type: ignore[union-attr]
class TestHandlePlanFileMoved:
"""Tests for handle_plan_file_moved."""
def test_updates_location_fields(self, tmp_path: Path) -> None:
"""Move updates location, relative_path, and file_path."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {
"0042": {
"status": "open",
"location": "/old/dir",
"relative_path": "old/dir",
"file_path": "/old/dir/FPLAN-0042.md",
}
},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
new_dir = tmp_path / "new" / "location"
new_dir.mkdir(parents=True, exist_ok=True)
dest_path = str(new_dir / "FPLAN-0042.md")
mod.handle_plan_file_moved(
src_path=str(tmp_path / "old" / "FPLAN-0042.md"),
dest_path=dest_path,
)
mod._save_registry.assert_called_once() # type: ignore[union-attr]
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
plan = saved["plans"]["0042"]
assert plan["file_path"] == dest_path
assert plan["location"] == str(new_dir)
assert "last_updated" in plan
def test_preserves_existing_metadata(self, tmp_path: Path) -> None:
"""Move preserves status, closed_reason, memory_created, etc."""
mod = _import_plan_file()
mod._load_registry = MagicMock(
return_value={
"plans": {
"0042": {
"status": "closed",
"closed_reason": "completed",
"memory_created": True,
"memory_created_date": "2026-01-15",
"location": "/old",
"relative_path": "old",
"file_path": "/old/FPLAN-0042.md",
}
},
"next_number": 43,
}
)
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
dest_path = str(tmp_path / "FPLAN-0042.md")
mod.handle_plan_file_moved(
src_path="/old/FPLAN-0042.md",
dest_path=dest_path,
)
saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr]
plan = saved["plans"]["0042"]
assert plan["status"] == "closed"
assert plan["closed_reason"] == "completed"
assert plan["memory_created"] is True
assert plan["memory_created_date"] == "2026-01-15"
def test_unknown_plan_no_crash(self, tmp_path: Path) -> None:
"""Plan not in registry causes no error on move."""
mod = _import_plan_file()
mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1})
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
mod.handle_plan_file_moved(
src_path=str(tmp_path / "FPLAN-9999.md"),
dest_path=str(tmp_path / "new" / "FPLAN-9999.md"),
)
mod._save_registry.assert_not_called() # type: ignore[union-attr]
def test_returns_early_for_non_plan_dest(self, tmp_path: Path) -> None:
"""Non-FPLAN destination filename causes immediate return."""
mod = _import_plan_file()
mod._load_registry = MagicMock()
mod._save_registry = MagicMock()
mod.REPO_ROOT = tmp_path
mod.handle_plan_file_moved(
src_path=str(tmp_path / "FPLAN-0001.md"),
dest_path=str(tmp_path / "renamed-notes.txt"),
)
mod._load_registry.assert_not_called() # type: ignore[union-attr]
@@ -0,0 +1,107 @@
# =================== AIPass ====================
# Name: test_startup_handler.py
# Description: Tests for startup event handler
# Version: 1.0.0
# Created: 2026-04-25
# Modified: 2026-04-25
# =============================================
"""Tests for startup event handler."""
import pytest
from unittest.mock import MagicMock
from pathlib import Path
@pytest.fixture(autouse=True)
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Mock heavy infrastructure imports before importing the handler module."""
import sys
from aipass.trigger.apps.config import atomic_write_json
mock_config = MagicMock()
mock_config.TRIGGER_ROOT = tmp_path
mock_config.atomic_write_json = atomic_write_json
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
mock_json_handler = MagicMock()
mock_json_handler.log_operation = MagicMock(return_value=True)
json_pkg = MagicMock()
json_pkg.json_handler = mock_json_handler
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
monkeypatch.setitem(
sys.modules,
"aipass.trigger.apps.handlers.json.json_handler",
mock_json_handler,
)
monkeypatch.delitem(
sys.modules,
"aipass.trigger.apps.handlers.events.startup",
raising=False,
)
def _import_startup():
"""Import fresh after mocking."""
import aipass.trigger.apps.handlers.events.startup as m
return m
class TestHandleStartup:
"""Tests for handle_startup."""
def test_calls_error_catchup_with_fire_event(self) -> None:
"""Passes fire_event kwarg to _run_error_catchup."""
mod = _import_startup()
mod._run_error_catchup = MagicMock()
mod._run_memory_check = MagicMock()
fire_event = MagicMock()
mod.handle_startup(fire_event=fire_event)
mod._run_error_catchup.assert_called_once_with(fire_event) # type: ignore[union-attr]
def test_calls_memory_check(self) -> None:
"""Invokes _run_memory_check on every startup."""
mod = _import_startup()
mod._run_error_catchup = MagicMock()
mod._run_memory_check = MagicMock()
mod.handle_startup()
mod._run_memory_check.assert_called_once() # type: ignore[union-attr]
def test_passes_none_when_no_fire_event(self) -> None:
"""Without fire_event kwarg, passes None to error catchup."""
mod = _import_startup()
mod._run_error_catchup = MagicMock()
mod._run_memory_check = MagicMock()
mod.handle_startup()
mod._run_error_catchup.assert_called_once_with(None) # type: ignore[union-attr]
def test_calls_both_helpers_in_order(self) -> None:
"""Error catchup runs before memory check."""
mod = _import_startup()
call_order: list[str] = []
mod._run_error_catchup = MagicMock(side_effect=lambda *a, **kw: call_order.append("catchup"))
mod._run_memory_check = MagicMock(side_effect=lambda *a, **kw: call_order.append("memory"))
mod.handle_startup(fire_event=MagicMock())
assert call_order == ["catchup", "memory"]
def test_extra_kwargs_do_not_crash(self) -> None:
"""Arbitrary extra kwargs are silently ignored."""
mod = _import_startup()
mod._run_error_catchup = MagicMock()
mod._run_memory_check = MagicMock()
mod.handle_startup(fire_event=MagicMock(), extra_arg="ignored", count=42)
mod._run_error_catchup.assert_called_once() # type: ignore[union-attr]
mod._run_memory_check.assert_called_once() # type: ignore[union-attr]