diff --git a/src/aipass/ai_mail/apps/__init__.py b/src/aipass/ai_mail/apps/__init__.py index 803548c8..de448f8b 100644 --- a/src/aipass/ai_mail/apps/__init__.py +++ b/src/aipass/ai_mail/apps/__init__.py @@ -1 +1,2 @@ # Apps package +from . import handlers # noqa: F401 diff --git a/src/aipass/prax/apps/__init__.py b/src/aipass/prax/apps/__init__.py index 73ab12a7..9fa3b6bc 100755 --- a/src/aipass/prax/apps/__init__.py +++ b/src/aipass/prax/apps/__init__.py @@ -1 +1,2 @@ # Apps package - Branch application modules and handlers +from . import handlers # noqa: F401 diff --git a/src/aipass/prax/tests/test_logging_handlers.py b/src/aipass/prax/tests/test_logging_handlers.py new file mode 100644 index 00000000..ff19cf34 --- /dev/null +++ b/src/aipass/prax/tests/test_logging_handlers.py @@ -0,0 +1,1096 @@ +# =================== AIPass ==================== +# Name: test_logging_handlers.py +# Description: Tests for prax logging handler modules +# Version: 1.0.0 +# Created: 2026-04-25 +# Modified: 2026-04-25 +# ============================================= + +"""Tests for prax logging handler modules. + +Covers: direct.py (doRollover), introspection.py (get_calling_module_path), +log_watchdog.py (get_oversized_files, truncate_log_file), +monitoring.py (run_monitoring_loop), operations.py (create_config_file), +override.py (enhanced_getLogger, install_logger_override, restore_original_logger), +setup.py (setup_system_logger, doRollover for _WindowsSafeRotatingHandler), +terminal/filtering.py (load_filtered_modules, should_display_terminal), +terminal/formatting.py (format_terminal_message, create_terminal_handler). + +All imports happen inside test functions because the autouse mock_prax_infrastructure +fixture must inject sys.modules mocks before any prax module is loaded. +""" + +import json +import logging +import sys +from unittest.mock import MagicMock, patch + + +# ============================================= +# direct.py -- RotatingFileHandler.doRollover +# ============================================= + + +class TestDirectRotatingFileHandlerDoRollover: + """Tests for direct.py RotatingFileHandler.doRollover().""" + + def test_do_rollover_success(self, mock_prax_infrastructure, tmp_path): + """doRollover delegates to parent class on success.""" + mock_config = MagicMock() + mock_config.get_system_logs_dir = MagicMock(return_value=tmp_path / "system") + mock_config.get_module_logs_dir = MagicMock(return_value=tmp_path / "local") + mock_config.DEFAULT_LOG_LEVEL = "DEBUG" + mock_config.load_log_config = MagicMock() + mock_config.lines_to_bytes = MagicMock(return_value=10000) + mock_config.PRAX_JSON_DIR = tmp_path / "prax_json" + + mock_introspection = MagicMock() + mock_introspection.detect_branch_from_path = MagicMock(return_value="prax") + + mock_override = MagicMock() + mock_override._original_getLogger = logging.getLogger + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.override": mock_override, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.direct", None) + from aipass.prax.apps.handlers.logging.direct import RotatingFileHandler + + log_file = tmp_path / "test.log" + log_file.write_text("line1\nline2\n", encoding="utf-8") + handler = RotatingFileHandler(str(log_file), maxBytes=10, backupCount=1) + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="A" * 100, + args=(), + exc_info=None, + ) + handler.emit(record) + backup = tmp_path / "test.log.1" + assert backup.exists() or log_file.exists() + handler.close() + + def test_do_rollover_permission_error_suppressed(self, mock_prax_infrastructure, tmp_path): + """doRollover suppresses PermissionError instead of crashing.""" + mock_config = MagicMock() + mock_config.get_system_logs_dir = MagicMock(return_value=tmp_path / "system") + mock_config.get_module_logs_dir = MagicMock(return_value=tmp_path / "local") + mock_config.DEFAULT_LOG_LEVEL = "DEBUG" + mock_config.load_log_config = MagicMock() + mock_config.lines_to_bytes = MagicMock(return_value=10000) + mock_config.PRAX_JSON_DIR = tmp_path / "prax_json" + + mock_introspection = MagicMock() + mock_override = MagicMock() + mock_override._original_getLogger = logging.getLogger + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.override": mock_override, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.direct", None) + from aipass.prax.apps.handlers.logging.direct import RotatingFileHandler + + log_file = tmp_path / "test_perm.log" + log_file.write_text("data\n", encoding="utf-8") + handler = RotatingFileHandler(str(log_file), maxBytes=10, backupCount=1) + + with patch( + "logging.handlers.RotatingFileHandler.doRollover", + side_effect=PermissionError("file locked"), + ): + handler.doRollover() + handler.close() + + def test_do_rollover_os_error_suppressed(self, mock_prax_infrastructure, tmp_path): + """doRollover suppresses OSError instead of crashing.""" + mock_config = MagicMock() + mock_config.get_system_logs_dir = MagicMock(return_value=tmp_path / "system") + mock_config.get_module_logs_dir = MagicMock(return_value=tmp_path / "local") + mock_config.DEFAULT_LOG_LEVEL = "DEBUG" + mock_config.load_log_config = MagicMock() + mock_config.lines_to_bytes = MagicMock(return_value=10000) + mock_config.PRAX_JSON_DIR = tmp_path / "prax_json" + + mock_introspection = MagicMock() + mock_override = MagicMock() + mock_override._original_getLogger = logging.getLogger + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.override": mock_override, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.direct", None) + from aipass.prax.apps.handlers.logging.direct import RotatingFileHandler + + log_file = tmp_path / "test_os.log" + log_file.write_text("data\n", encoding="utf-8") + handler = RotatingFileHandler(str(log_file), maxBytes=10, backupCount=1) + + with patch( + "logging.handlers.RotatingFileHandler.doRollover", + side_effect=OSError("disk error"), + ): + handler.doRollover() + handler.close() + + +# ============================================= +# introspection.py -- get_calling_module_path +# ============================================= + + +class TestGetCallingModulePath: + """Tests for introspection.py get_calling_module_path().""" + + def test_returns_path_or_none(self, mock_prax_infrastructure): + """get_calling_module_path returns a string path or None.""" + from aipass.prax.apps.handlers.logging.introspection import get_calling_module_path + + result = get_calling_module_path() + assert result is None or isinstance(result, str) + + def test_returns_none_when_no_external_caller(self, mock_prax_infrastructure): + """Returns None when _find_external_caller_path returns None.""" + from aipass.prax.apps.handlers.logging import introspection + + with patch.object(introspection, "_find_external_caller_path", return_value=None): + result = introspection.get_calling_module_path() + assert result is None + + def test_returns_path_when_external_caller_found(self, mock_prax_infrastructure): + """Returns the path from _find_external_caller_path when found.""" + from aipass.prax.apps.handlers.logging import introspection + + fake_path = "/home/user/src/aipass/flow/apps/flow.py" + with patch.object(introspection, "_find_external_caller_path", return_value=fake_path): + result = introspection.get_calling_module_path() + assert result == fake_path + + +# ============================================= +# log_watchdog.py -- get_oversized_files +# ============================================= + + +class TestGetOversizedFiles: + """Tests for log_watchdog.py get_oversized_files().""" + + def test_returns_empty_when_no_oversized(self, mock_prax_infrastructure, tmp_path): + """Returns empty list when no files exceed threshold.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path / "prax_json", + ), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.log_watchdog", None) + import aipass.prax.apps.handlers.logging.log_watchdog as lw + + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + small_log = logs_dir / "small.log" + small_log.write_text("line1\nline2\nline3\n", encoding="utf-8") + + with patch.object(lw, "_get_system_logs_dir", return_value=logs_dir): + result = lw.get_oversized_files(threshold=100) + assert result == [] + + def test_returns_oversized_files(self, mock_prax_infrastructure, tmp_path): + """Returns files that exceed the threshold.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path / "prax_json", + ), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.log_watchdog", None) + import aipass.prax.apps.handlers.logging.log_watchdog as lw + + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + big_log = logs_dir / "big.log" + big_log.write_text( + "\n".join(f"line {i}" for i in range(200)) + "\n", + encoding="utf-8", + ) + + with patch.object(lw, "_get_system_logs_dir", return_value=logs_dir): + result = lw.get_oversized_files(threshold=100) + assert len(result) == 1 + assert result[0]["name"] == "big.log" + assert result[0]["lines"] >= 100 + + def test_nonexistent_dir_returns_empty(self, mock_prax_infrastructure, tmp_path): + """Returns empty list when system_logs dir does not exist.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path / "prax_json", + ), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.log_watchdog", None) + import aipass.prax.apps.handlers.logging.log_watchdog as lw + + nonexistent = tmp_path / "does_not_exist" + with patch.object(lw, "_get_system_logs_dir", return_value=nonexistent): + result = lw.get_oversized_files() + assert result == [] + + +# ============================================= +# log_watchdog.py -- truncate_log_file +# ============================================= + + +class TestTruncateLogFile: + """Tests for log_watchdog.py truncate_log_file().""" + + def test_truncates_oversized_file(self, mock_prax_infrastructure, tmp_path): + """Truncates file to keep_lines and adds truncation marker.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path / "prax_json", + ), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.log_watchdog", None) + import aipass.prax.apps.handlers.logging.log_watchdog as lw + + log_file = tmp_path / "truncate_me.log" + lines = [f"line {i}\n" for i in range(500)] + log_file.write_text("".join(lines), encoding="utf-8") + + original, new = lw.truncate_log_file(log_file, keep_lines=100) + assert original == 500 + assert new == 101 # 100 kept lines + 1 marker + + content = log_file.read_text(encoding="utf-8") + assert "LOG TRUNCATED by PRAX watchdog" in content + + def test_no_truncation_when_under_limit(self, mock_prax_infrastructure, tmp_path): + """File under limit is not modified.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path / "prax_json", + ), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.log_watchdog", None) + import aipass.prax.apps.handlers.logging.log_watchdog as lw + + log_file = tmp_path / "small.log" + lines = [f"line {i}\n" for i in range(50)] + log_file.write_text("".join(lines), encoding="utf-8") + + original, new = lw.truncate_log_file(log_file, keep_lines=100) + assert original == 50 + assert new == 50 + + def test_truncate_handles_os_error(self, mock_prax_infrastructure, tmp_path): + """Returns (0, 0) on OSError.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path / "prax_json", + ), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.log_watchdog", None) + import aipass.prax.apps.handlers.logging.log_watchdog as lw + + nonexistent = tmp_path / "does_not_exist.log" + original, new = lw.truncate_log_file(nonexistent, keep_lines=100) + assert original == 0 + assert new == 0 + + +# ============================================= +# monitoring.py -- run_monitoring_loop +# ============================================= + + +class TestRunMonitoringLoop: + """Tests for monitoring.py run_monitoring_loop().""" + + def test_loop_runs_and_handles_keyboard_interrupt(self, mock_prax_infrastructure): + """Loop exits cleanly on KeyboardInterrupt and re-raises it.""" + import pytest + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock(PRAX_JSON_DIR=MagicMock()), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.monitoring", None) + import aipass.prax.apps.handlers.logging.monitoring as mon + + status_cb = MagicMock( + return_value={"total_modules": 5, "individual_loggers": 3}, + ) + + with patch.object(mon.time, "sleep", side_effect=KeyboardInterrupt): + with pytest.raises(KeyboardInterrupt): + mon.run_monitoring_loop(status_cb, interval=1, status_interval=60) + + def test_loop_calls_status_callback_at_interval(self, mock_prax_infrastructure): + """Loop calls status_callback when counter hits status_interval.""" + import pytest + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock(PRAX_JSON_DIR=MagicMock()), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.monitoring", None) + import aipass.prax.apps.handlers.logging.monitoring as mon + + status_cb = MagicMock( + return_value={"total_modules": 2, "individual_loggers": 1}, + ) + call_count = 0 + + def fake_sleep(seconds): + nonlocal call_count + call_count += 1 + if call_count >= 6: + raise KeyboardInterrupt + + with patch.object(mon.time, "sleep", side_effect=fake_sleep): + with pytest.raises(KeyboardInterrupt): + mon.run_monitoring_loop(status_cb, interval=5, status_interval=5) + + assert status_cb.called + + def test_loop_flushes_stdout(self, mock_prax_infrastructure): + """Loop flushes sys.stdout during startup.""" + import pytest + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock(PRAX_JSON_DIR=MagicMock()), + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.monitoring", None) + import aipass.prax.apps.handlers.logging.monitoring as mon + + status_cb = MagicMock(return_value={}) + + with patch.object(mon.time, "sleep", side_effect=KeyboardInterrupt): + with patch.object(mon.sys, "stdout") as mock_stdout: + with pytest.raises(KeyboardInterrupt): + mon.run_monitoring_loop(status_cb) + mock_stdout.flush.assert_called() + + +# ============================================= +# operations.py -- create_config_file +# ============================================= + + +class TestCreateConfigFile: + """Tests for operations.py create_config_file().""" + + def test_creates_config_when_missing(self, mock_prax_infrastructure, tmp_path): + """Creates default config file when it does not exist.""" + mock_config = MagicMock() + mock_config.PRAX_JSON_DIR = tmp_path + + mock_direct = MagicMock() + mock_direct_logger = MagicMock() + mock_direct.get_direct_logger = MagicMock(return_value=mock_direct_logger) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.direct": mock_direct, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.operations", None) + import aipass.prax.apps.handlers.logging.operations as ops + + config_path = tmp_path / "prax_logger_config.json" + ops.CONFIG_FILE = config_path + + ops.create_config_file() + + assert config_path.exists() + content = json.loads(config_path.read_text(encoding="utf-8")) + assert content["module_name"] == "prax_logger" + assert "config" in content + assert content["config"]["log_level"] == "INFO" + + def test_does_not_overwrite_existing_config(self, mock_prax_infrastructure, tmp_path): + """Does not overwrite an existing config file.""" + mock_config = MagicMock() + mock_config.PRAX_JSON_DIR = tmp_path + + mock_direct = MagicMock() + mock_direct_logger = MagicMock() + mock_direct.get_direct_logger = MagicMock(return_value=mock_direct_logger) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.direct": mock_direct, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.operations", None) + import aipass.prax.apps.handlers.logging.operations as ops + + config_path = tmp_path / "prax_logger_config.json" + config_path.write_text('{"existing": true}', encoding="utf-8") + ops.CONFIG_FILE = config_path + + ops.create_config_file() + + content = json.loads(config_path.read_text(encoding="utf-8")) + assert content == {"existing": True} + + def test_handles_write_error_gracefully(self, mock_prax_infrastructure, tmp_path): + """Handles write errors without raising.""" + mock_config = MagicMock() + mock_config.PRAX_JSON_DIR = tmp_path + + mock_direct = MagicMock() + mock_direct_logger = MagicMock() + mock_direct.get_direct_logger = MagicMock(return_value=mock_direct_logger) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.direct": mock_direct, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.operations", None) + import aipass.prax.apps.handlers.logging.operations as ops + + config_path = tmp_path / "nonexistent_dir" / "config.json" + ops.CONFIG_FILE = config_path + + # Should not raise even when file creation fails + ops.create_config_file() + + +# ============================================= +# override.py -- enhanced_getLogger +# ============================================= + + +class TestEnhancedGetLogger: + """Tests for override.py enhanced_getLogger().""" + + def test_returns_logger_for_known_module(self, mock_prax_infrastructure): + """Returns a configured logger when module is detected.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + mock_config.get_system_logs_dir = MagicMock() + mock_config.get_module_logs_dir = MagicMock() + mock_config.load_log_config = MagicMock() + mock_config.lines_to_bytes = MagicMock() + + mock_introspection = MagicMock() + mock_introspection.get_calling_module = MagicMock(return_value="test_module") + + mock_setup = MagicMock() + mock_individual_logger = MagicMock() + mock_individual_logger.handlers = [MagicMock()] + mock_setup.setup_individual_logger = MagicMock( + return_value=mock_individual_logger, + ) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.setup": mock_setup, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.override", None) + import aipass.prax.apps.handlers.logging.override as ov + + result = ov.enhanced_getLogger("some.name") + assert isinstance(result, logging.Logger) + mock_setup.setup_individual_logger.assert_called_once_with("test_module") + + def test_returns_original_logger_for_unknown_module(self, mock_prax_infrastructure): + """Falls back to original logger when module is unknown.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + + mock_introspection = MagicMock() + mock_introspection.get_calling_module = MagicMock(return_value="unknown_module") + + mock_setup = MagicMock() + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.setup": mock_setup, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.override", None) + import aipass.prax.apps.handlers.logging.override as ov + + result = ov.enhanced_getLogger("some.name") + assert isinstance(result, logging.Logger) + mock_setup.setup_individual_logger.assert_not_called() + + def test_debug_prints_when_enabled(self, mock_prax_infrastructure): + """Writes to stderr when debug prints are enabled.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_debug_prints_enabled = MagicMock(return_value=True) + + mock_introspection = MagicMock() + mock_introspection.get_calling_module = MagicMock(return_value="unknown_module") + + mock_setup = MagicMock() + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.setup": mock_setup, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.override", None) + import aipass.prax.apps.handlers.logging.override as ov + + with patch("sys.stderr") as mock_stderr: + ov.enhanced_getLogger("debug_test") + mock_stderr.write.assert_called() + + +# ============================================= +# override.py -- install_logger_override / restore_original_logger +# ============================================= + + +class TestInstallRestoreLoggerOverride: + """Tests for override.py install/restore functions.""" + + def test_install_replaces_getlogger(self, mock_prax_infrastructure): + """install_logger_override replaces logging.getLogger.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + + mock_introspection = MagicMock() + mock_setup = MagicMock() + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.setup": mock_setup, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.override", None) + import aipass.prax.apps.handlers.logging.override as ov + + original_fn = logging.getLogger + try: + ov.install_logger_override() + assert logging.getLogger is ov.enhanced_getLogger + finally: + logging.getLogger = original_fn + + def test_restore_puts_back_original(self, mock_prax_infrastructure): + """restore_original_logger restores the stdlib getLogger.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + + mock_introspection = MagicMock() + mock_setup = MagicMock() + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.setup": mock_setup, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.override", None) + import aipass.prax.apps.handlers.logging.override as ov + + original_fn = logging.getLogger + try: + ov.install_logger_override() + assert logging.getLogger is ov.enhanced_getLogger + ov.restore_original_logger() + assert logging.getLogger is ov._original_getLogger + finally: + logging.getLogger = original_fn + + def test_is_override_active_reflects_state(self, mock_prax_infrastructure): + """is_override_active returns correct state.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + + mock_introspection = MagicMock() + mock_setup = MagicMock() + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_introspection, + "aipass.prax.apps.handlers.logging.setup": mock_setup, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.override", None) + import aipass.prax.apps.handlers.logging.override as ov + + original_fn = logging.getLogger + try: + ov.restore_original_logger() + assert ov.is_override_active() is False + ov.install_logger_override() + assert ov.is_override_active() is True + finally: + logging.getLogger = original_fn + + +# ============================================= +# setup.py -- setup_system_logger +# ============================================= + + +class TestSetupSystemLogger: + """Tests for setup.py setup_system_logger().""" + + def _make_mocks(self, tmp_path): + """Build the standard mock set for setup.py tests.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_system_logs_dir = MagicMock(return_value=tmp_path / "system") + mock_config.get_module_logs_dir = MagicMock(return_value=tmp_path / "local") + mock_config.load_log_config = MagicMock(return_value={ + "log_format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s", + "date_format": "%Y-%m-%d %H:%M:%S", + "system_logs": {"max_lines": 1000, "backup_count": 3}, + "local_logs": {"max_lines": 500, "backup_count": 2}, + }) + mock_config.lines_to_bytes = MagicMock(return_value=200000) + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + mock_config.PRAX_JSON_DIR = tmp_path / "prax_json" + + mock_introspection = MagicMock() + mock_introspection.get_calling_module_path = MagicMock(return_value=None) + mock_introspection.detect_branch_from_path = MagicMock(return_value=None) + + mock_filtering = MagicMock() + mock_filtering.should_display_terminal = MagicMock(return_value=False) + + mock_formatting = MagicMock() + mock_formatting.create_terminal_handler = MagicMock() + + (tmp_path / "system").mkdir(parents=True, exist_ok=True) + (tmp_path / "local").mkdir(parents=True, exist_ok=True) + + return mock_config, mock_introspection, mock_filtering, mock_formatting + + def test_creates_system_logger(self, mock_prax_infrastructure, tmp_path): + """setup_system_logger creates and returns a logger.""" + mock_config, mock_intro, mock_filt, mock_fmt = self._make_mocks(tmp_path) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_intro, + "aipass.prax.apps.handlers.logging.terminal.filtering": mock_filt, + "aipass.prax.apps.handlers.logging.terminal.formatting": mock_fmt, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.setup", None) + import aipass.prax.apps.handlers.logging.setup as setup_mod + + setup_mod._system_logger = None + setup_mod._captured_loggers.clear() + + result = setup_mod.setup_system_logger() + assert isinstance(result, logging.Logger) + assert result.name == "prax_system_logger" + assert len(result.handlers) >= 2 + + def test_returns_cached_logger_on_second_call(self, mock_prax_infrastructure, tmp_path): + """Second call returns the same cached logger.""" + mock_config, mock_intro, mock_filt, mock_fmt = self._make_mocks(tmp_path) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_intro, + "aipass.prax.apps.handlers.logging.terminal.filtering": mock_filt, + "aipass.prax.apps.handlers.logging.terminal.formatting": mock_fmt, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.setup", None) + import aipass.prax.apps.handlers.logging.setup as setup_mod + + setup_mod._system_logger = None + setup_mod._captured_loggers.clear() + + first = setup_mod.setup_system_logger() + second = setup_mod.setup_system_logger() + assert first is second + + +# ============================================= +# setup.py -- _WindowsSafeRotatingHandler.doRollover +# ============================================= + + +class TestSetupWindowsSafeRotatingHandlerDoRollover: + """Tests for setup.py _WindowsSafeRotatingHandler.doRollover().""" + + def _make_mocks(self, tmp_path): + """Build the standard mock set for setup.py handler tests.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.get_system_logs_dir = MagicMock(return_value=tmp_path) + mock_config.get_module_logs_dir = MagicMock(return_value=tmp_path) + mock_config.load_log_config = MagicMock(return_value={ + "log_format": "%(message)s", + "date_format": "%H:%M:%S", + "system_logs": {"max_lines": 100, "backup_count": 1}, + "local_logs": {"max_lines": 100, "backup_count": 1}, + }) + mock_config.lines_to_bytes = MagicMock(return_value=20000) + mock_config.get_debug_prints_enabled = MagicMock(return_value=False) + mock_config.PRAX_JSON_DIR = tmp_path / "prax_json" + + mock_introspection = MagicMock() + mock_introspection.get_calling_module_path = MagicMock(return_value=None) + mock_introspection.detect_branch_from_path = MagicMock(return_value=None) + + mock_filtering = MagicMock() + mock_formatting = MagicMock() + + return mock_config, mock_introspection, mock_filtering, mock_formatting + + def test_do_rollover_success(self, mock_prax_infrastructure, tmp_path): + """Normal rollover succeeds.""" + mock_config, mock_intro, mock_filt, mock_fmt = self._make_mocks(tmp_path) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_intro, + "aipass.prax.apps.handlers.logging.terminal.filtering": mock_filt, + "aipass.prax.apps.handlers.logging.terminal.formatting": mock_fmt, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.setup", None) + import aipass.prax.apps.handlers.logging.setup as setup_mod + + log_file = tmp_path / "rollover_test.log" + log_file.write_text("data\n", encoding="utf-8") + handler = setup_mod._WindowsSafeRotatingHandler( + str(log_file), + maxBytes=10, + backupCount=1, + encoding="utf-8", + ) + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="A" * 50, + args=(), + exc_info=None, + ) + handler.emit(record) + handler.close() + assert log_file.exists() or (tmp_path / "rollover_test.log.1").exists() + + def test_do_rollover_permission_error_suppressed( + self, mock_prax_infrastructure, tmp_path + ): + """PermissionError during rollover is caught, not raised.""" + mock_config, mock_intro, mock_filt, mock_fmt = self._make_mocks(tmp_path) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.introspection": mock_intro, + "aipass.prax.apps.handlers.logging.terminal.filtering": mock_filt, + "aipass.prax.apps.handlers.logging.terminal.formatting": mock_fmt, + }): + sys.modules.pop("aipass.prax.apps.handlers.logging.setup", None) + import aipass.prax.apps.handlers.logging.setup as setup_mod + + log_file = tmp_path / "perm_test.log" + log_file.write_text("data\n", encoding="utf-8") + handler = setup_mod._WindowsSafeRotatingHandler( + str(log_file), + maxBytes=10, + backupCount=1, + encoding="utf-8", + ) + + with patch( + "logging.handlers.RotatingFileHandler.doRollover", + side_effect=PermissionError("locked"), + ): + handler.doRollover() + handler.close() + + +# ============================================= +# terminal/filtering.py -- load_filtered_modules +# ============================================= + + +class TestLoadFilteredModules: + """Tests for terminal/filtering.py load_filtered_modules().""" + + def test_returns_defaults_when_no_config(self, mock_prax_infrastructure, tmp_path): + """Returns DEFAULT_FILTERED_MODULES when config file absent.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path, + ), + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.filtering", None + ) + import aipass.prax.apps.handlers.logging.terminal.filtering as filt + + filt.CONFIG_FILE = tmp_path / "nonexistent_config.json" + + result = filt.load_filtered_modules() + assert result == filt.DEFAULT_FILTERED_MODULES + + def test_loads_from_config_file(self, mock_prax_infrastructure, tmp_path): + """Loads filtered modules from config file when present.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path, + ), + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.filtering", None + ) + import aipass.prax.apps.handlers.logging.terminal.filtering as filt + + config_file = tmp_path / "prax_terminal_config.json" + config_file.write_text( + json.dumps({"filtered_modules": ["custom_mod", "another_mod"]}), + encoding="utf-8", + ) + filt.CONFIG_FILE = config_file + + result = filt.load_filtered_modules() + assert result == {"custom_mod", "another_mod"} + + def test_returns_defaults_on_corrupt_config( + self, mock_prax_infrastructure, tmp_path + ): + """Returns defaults when config file is corrupt JSON.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path, + ), + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.filtering", None + ) + import aipass.prax.apps.handlers.logging.terminal.filtering as filt + + config_file = tmp_path / "prax_terminal_config.json" + config_file.write_text("NOT VALID JSON{{{{", encoding="utf-8") + filt.CONFIG_FILE = config_file + + result = filt.load_filtered_modules() + assert result == filt.DEFAULT_FILTERED_MODULES + + +# ============================================= +# terminal/filtering.py -- should_display_terminal +# ============================================= + + +class TestShouldDisplayTerminal: + """Tests for terminal/filtering.py should_display_terminal().""" + + def test_external_module_displayed(self, mock_prax_infrastructure, tmp_path): + """External module is not filtered and should display.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path, + ), + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.filtering", None + ) + import aipass.prax.apps.handlers.logging.terminal.filtering as filt + + result = filt.should_display_terminal( + "flow", filtered_modules={"prax_logger"} + ) + assert result is True + + def test_filtered_module_not_displayed(self, mock_prax_infrastructure, tmp_path): + """Internal prax module is filtered and should not display.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path, + ), + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.filtering", None + ) + import aipass.prax.apps.handlers.logging.terminal.filtering as filt + + result = filt.should_display_terminal( + "prax_logger", filtered_modules={"prax_logger"} + ) + assert result is False + + def test_loads_from_config_when_no_set_provided( + self, mock_prax_infrastructure, tmp_path + ): + """Loads filtered modules from config when not explicitly passed.""" + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": MagicMock( + PRAX_JSON_DIR=tmp_path, + ), + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.filtering", None + ) + import aipass.prax.apps.handlers.logging.terminal.filtering as filt + + filt.CONFIG_FILE = tmp_path / "nonexistent.json" + # "drone" is not in defaults, so it should display + result = filt.should_display_terminal("drone") + assert result is True + + # "prax_logger" IS in defaults, so it should NOT display + result = filt.should_display_terminal("prax_logger") + assert result is False + + +# ============================================= +# terminal/formatting.py -- format_terminal_message +# ============================================= + + +class TestFormatTerminalMessage: + """Tests for terminal/formatting.py format_terminal_message().""" + + def _import_formatting(self, tmp_path): + """Import formatting module with mocked dependencies.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.PRAX_JSON_DIR = tmp_path + + mock_filtering = MagicMock() + mock_filtering.should_display_terminal = MagicMock(return_value=True) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.terminal.filtering": mock_filtering, + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.formatting", None + ) + import aipass.prax.apps.handlers.logging.terminal.formatting as fmt + + return fmt + + def test_format_with_branch(self, mock_prax_infrastructure, tmp_path): + """Formats message with branch label.""" + fmt = self._import_formatting(tmp_path) + + record = logging.LogRecord( + name="captured_flow_module", + level=logging.INFO, + pathname="", + lineno=0, + msg="Hello world", + args=(), + exc_info=None, + ) + + result = fmt.format_terminal_message(record, branch="flow") + assert "[flow]" in result + assert "flow_module" in result + assert "INFO" in result + assert "Hello world" in result + + def test_format_without_branch_uses_system(self, mock_prax_infrastructure, tmp_path): + """Formats message with SYSTEM label when no branch given.""" + fmt = self._import_formatting(tmp_path) + + record = logging.LogRecord( + name="captured_test", + level=logging.WARNING, + pathname="", + lineno=0, + msg="Warning msg", + args=(), + exc_info=None, + ) + + result = fmt.format_terminal_message(record) + assert "[SYSTEM]" in result + assert "WARNING" in result + + def test_format_strips_captured_prefix(self, mock_prax_infrastructure, tmp_path): + """Removes 'captured_' prefix from logger name in output.""" + fmt = self._import_formatting(tmp_path) + + record = logging.LogRecord( + name="captured_my_mod", + level=logging.ERROR, + pathname="", + lineno=0, + msg="error!", + args=(), + exc_info=None, + ) + + result = fmt.format_terminal_message(record, branch="prax") + assert "captured_" not in result + assert "my_mod" in result + + def test_format_non_captured_logger_name(self, mock_prax_infrastructure, tmp_path): + """Logger name without captured_ prefix is used as-is.""" + fmt = self._import_formatting(tmp_path) + + record = logging.LogRecord( + name="plain_logger", + level=logging.DEBUG, + pathname="", + lineno=0, + msg="debug", + args=(), + exc_info=None, + ) + + result = fmt.format_terminal_message(record, branch="cli") + assert "plain_logger" in result + + +# ============================================= +# terminal/formatting.py -- create_terminal_handler +# ============================================= + + +class TestCreateTerminalHandler: + """Tests for terminal/formatting.py create_terminal_handler().""" + + def _import_formatting(self, tmp_path): + """Import formatting module with mocked dependencies.""" + mock_config = MagicMock() + mock_config.DEFAULT_LOG_LEVEL = logging.DEBUG + mock_config.PRAX_JSON_DIR = tmp_path + + mock_filtering = MagicMock() + mock_filtering.should_display_terminal = MagicMock(return_value=True) + + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.logging.terminal.filtering": mock_filtering, + }): + sys.modules.pop( + "aipass.prax.apps.handlers.logging.terminal.formatting", None + ) + import aipass.prax.apps.handlers.logging.terminal.formatting as fmt + + return fmt + + def test_returns_stream_handler(self, mock_prax_infrastructure, tmp_path): + """Returns a StreamHandler instance.""" + fmt = self._import_formatting(tmp_path) + handler = fmt.create_terminal_handler() + assert isinstance(handler, logging.StreamHandler) + + def test_handler_has_terminal_formatter(self, mock_prax_infrastructure, tmp_path): + """Handler uses TerminalFormatter.""" + fmt = self._import_formatting(tmp_path) + handler = fmt.create_terminal_handler() + assert isinstance(handler.formatter, fmt.TerminalFormatter) + + def test_handler_writes_to_stdout(self, mock_prax_infrastructure, tmp_path): + """Handler stream is sys.stdout.""" + fmt = self._import_formatting(tmp_path) + handler = fmt.create_terminal_handler() + assert handler.stream is sys.stdout diff --git a/src/aipass/prax/tests/test_monitoring_handlers.py b/src/aipass/prax/tests/test_monitoring_handlers.py new file mode 100644 index 00000000..d7a012cb --- /dev/null +++ b/src/aipass/prax/tests/test_monitoring_handlers.py @@ -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 diff --git a/src/aipass/prax/tests/test_operations.py b/src/aipass/prax/tests/test_operations.py index e55bb286..1a23f3b5 100644 --- a/src/aipass/prax/tests/test_operations.py +++ b/src/aipass/prax/tests/test_operations.py @@ -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() diff --git a/src/aipass/spawn/templates/builder/apps/__init__.py b/src/aipass/spawn/templates/builder/apps/__init__.py index 91047046..56b51419 100644 --- a/src/aipass/spawn/templates/builder/apps/__init__.py +++ b/src/aipass/spawn/templates/builder/apps/__init__.py @@ -1 +1,2 @@ # {{BRANCHNAME}} apps package +from . import handlers # noqa: F401 diff --git a/src/aipass/trigger/apps/__init__.py b/src/aipass/trigger/apps/__init__.py index 73ab12a7..9fa3b6bc 100644 --- a/src/aipass/trigger/apps/__init__.py +++ b/src/aipass/trigger/apps/__init__.py @@ -1 +1,2 @@ # Apps package - Branch application modules and handlers +from . import handlers # noqa: F401 diff --git a/src/aipass/trigger/tests/test_error_detected.py b/src/aipass/trigger/tests/test_error_detected.py new file mode 100644 index 00000000..f7a021a9 --- /dev/null +++ b/src/aipass/trigger/tests/test_error_detected.py @@ -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 diff --git a/src/aipass/trigger/tests/test_event_handlers.py b/src/aipass/trigger/tests/test_event_handlers.py new file mode 100644 index 00000000..1d9da2d7 --- /dev/null +++ b/src/aipass/trigger/tests/test_event_handlers.py @@ -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 diff --git a/src/aipass/trigger/tests/test_json_handler.py b/src/aipass/trigger/tests/test_json_handler.py index abd1acf8..e86c8312 100644 --- a/src/aipass/trigger/tests/test_json_handler.py +++ b/src/aipass/trigger/tests/test_json_handler.py @@ -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 diff --git a/src/aipass/trigger/tests/test_memory_threshold_handler.py b/src/aipass/trigger/tests/test_memory_threshold_handler.py new file mode 100644 index 00000000..0e20a897 --- /dev/null +++ b/src/aipass/trigger/tests/test_memory_threshold_handler.py @@ -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} + ) diff --git a/src/aipass/trigger/tests/test_plan_file_handler.py b/src/aipass/trigger/tests/test_plan_file_handler.py new file mode 100644 index 00000000..623ff35d --- /dev/null +++ b/src/aipass/trigger/tests/test_plan_file_handler.py @@ -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] diff --git a/src/aipass/trigger/tests/test_startup_handler.py b/src/aipass/trigger/tests/test_startup_handler.py new file mode 100644 index 00000000..dca67903 --- /dev/null +++ b/src/aipass/trigger/tests/test_startup_handler.py @@ -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]