From 38026271ef31b16bcf4cd0ecfb9ae590ea41e61b Mon Sep 17 00:00:00 2001 From: AIPass Date: Sun, 5 Apr 2026 11:56:18 -0700 Subject: [PATCH] feat(system): add S73 test coverage files for 12 branches (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(system): feat(system): FPLAN-0164 dispatch safety net + morning briefing timeout fix Co-Authored-By: @devpulse * feat(system): fix(drone+backup): resolver BranchNotFoundError handling + backup snapshot quick-check Co-Authored-By: @devpulse * feat(system): fix(ai_mail): test fix for JSONL-based startup detection in dispatch_monitor Co-Authored-By: @devpulse * feat(system): feat(daemon): expand test coverage from 12% — 74 new tests for scheduler_cron and actions Co-Authored-By: @devpulse * feat(system): S73 night shift: test coverage push to 100% + seedgo test depth fixes Co-Authored-By: @devpulse * feat(system): README overhaul + HERALD update + S73 test files Co-Authored-By: @devpulse * feat(system): S73 night shift: 51 new test files across 12 branches Co-Authored-By: @devpulse --------- Co-authored-by: @devpulse --- src/aipass/ai_mail/tests/test_branch_ping.py | 263 ++++ .../ai_mail/tests/test_central_writer.py | 359 +++++ .../ai_mail/tests/test_dispatch_monitor.py | 1154 +++++++++++++++++ src/aipass/ai_mail/tests/test_notify.py | 196 +++ src/aipass/ai_mail/tests/test_paths.py | 86 ++ src/aipass/api/tests/test_caller.py | 91 ++ src/aipass/api/tests/test_config_provider.py | 199 +++ src/aipass/api/tests/test_tracking.py | 354 +++++ .../backup/tests/test_drive_sync_client.py | 596 +++++++++ src/aipass/backup/tests/test_system_utils.py | 176 +++ src/aipass/cli/tests/test_init_project.py | 183 +++ .../daemon/tests/test_actions_module.py | 456 +++++++ .../daemon/tests/test_activity_report.py | 243 ++++ .../daemon/tests/test_schedule_module.py | 304 +++++ .../daemon/tests/test_scheduler_cron.py | 479 +++++++ src/aipass/daemon/tests/test_scheduler_ops.py | 114 ++ src/aipass/daemon/tests/test_wakeup_ops.py | 94 ++ src/aipass/drone/tests/test_config.py | 249 ++++ .../drone/tests/test_generic_adapter.py | 314 +++++ .../drone/tests/test_module_registry.py | 398 ++++++ src/aipass/flow/tests/test_mbank_template.py | 1098 ++++++++++++++++ .../flow/tests/test_monitor_registry.py | 774 +++++++++++ src/aipass/flow/tests/test_plan_handlers.py | 761 +++++++++++ src/aipass/memory/tests/test_archive.py | 556 ++++++++ .../memory/tests/test_central_writer.py | 508 ++++++++ .../memory/tests/test_dashboard_push.py | 616 +++++++++ src/aipass/memory/tests/test_intake.py | 691 ++++++++++ src/aipass/memory/tests/test_learnings.py | 662 ++++++++++ src/aipass/memory/tests/test_storage.py | 577 +++++++++ src/aipass/memory/tests/test_vector.py | 359 +++++ src/aipass/prax/tests/test_central.py | 248 ++++ src/aipass/prax/tests/test_log_watcher.py | 449 +++++++ src/aipass/prax/tests/test_logger_module.py | 628 +++++++++ src/aipass/prax/tests/test_monitor_module.py | 224 ++++ src/aipass/prax/tests/test_registry.py | 339 +++++ src/aipass/prax/tests/test_watcher.py | 333 +++++ src/aipass/spawn/tests/test_file_ops.py | 450 +++++++ .../tests/test_regenerate_registry_ops.py | 540 ++++++++ .../trigger/tests/test_branch_log_events.py | 424 ++++++ .../trigger/tests/test_error_reporter.py | 596 +++++++++ src/aipass/trigger/tests/test_log_events.py | 340 +++++ src/aipass/trigger/tests/test_log_watcher.py | 512 ++++++++ src/aipass/trigger/tests/test_medic_state.py | 496 +++++++ .../tests/test_watchers_log_watcher.py | 350 +++++ src/commons/tests/test_comments_posts.py | 714 ++++++++++ .../test_curation_explore_welcome_ops.py | 1139 ++++++++++++++++ src/commons/tests/test_notification_ops.py | 812 ++++++++++++ src/skills/tests/test_creator.py | 164 +++ src/skills/tests/test_creator_handler.py | 171 +++ src/skills/tests/test_template.py | 235 ++++ 50 files changed, 22074 insertions(+) create mode 100644 src/aipass/ai_mail/tests/test_branch_ping.py create mode 100644 src/aipass/ai_mail/tests/test_central_writer.py create mode 100644 src/aipass/ai_mail/tests/test_dispatch_monitor.py create mode 100644 src/aipass/ai_mail/tests/test_notify.py create mode 100644 src/aipass/ai_mail/tests/test_paths.py create mode 100644 src/aipass/api/tests/test_caller.py create mode 100644 src/aipass/api/tests/test_config_provider.py create mode 100644 src/aipass/api/tests/test_tracking.py create mode 100644 src/aipass/backup/tests/test_drive_sync_client.py create mode 100644 src/aipass/backup/tests/test_system_utils.py create mode 100644 src/aipass/cli/tests/test_init_project.py create mode 100644 src/aipass/daemon/tests/test_actions_module.py create mode 100644 src/aipass/daemon/tests/test_activity_report.py create mode 100644 src/aipass/daemon/tests/test_schedule_module.py create mode 100644 src/aipass/daemon/tests/test_scheduler_cron.py create mode 100644 src/aipass/daemon/tests/test_scheduler_ops.py create mode 100644 src/aipass/daemon/tests/test_wakeup_ops.py create mode 100644 src/aipass/drone/tests/test_config.py create mode 100644 src/aipass/drone/tests/test_generic_adapter.py create mode 100644 src/aipass/drone/tests/test_module_registry.py create mode 100644 src/aipass/flow/tests/test_mbank_template.py create mode 100644 src/aipass/flow/tests/test_monitor_registry.py create mode 100644 src/aipass/flow/tests/test_plan_handlers.py create mode 100644 src/aipass/memory/tests/test_archive.py create mode 100644 src/aipass/memory/tests/test_central_writer.py create mode 100644 src/aipass/memory/tests/test_dashboard_push.py create mode 100644 src/aipass/memory/tests/test_intake.py create mode 100644 src/aipass/memory/tests/test_learnings.py create mode 100644 src/aipass/memory/tests/test_storage.py create mode 100644 src/aipass/memory/tests/test_vector.py create mode 100644 src/aipass/prax/tests/test_central.py create mode 100644 src/aipass/prax/tests/test_log_watcher.py create mode 100644 src/aipass/prax/tests/test_logger_module.py create mode 100644 src/aipass/prax/tests/test_monitor_module.py create mode 100644 src/aipass/prax/tests/test_registry.py create mode 100644 src/aipass/prax/tests/test_watcher.py create mode 100644 src/aipass/spawn/tests/test_file_ops.py create mode 100644 src/aipass/spawn/tests/test_regenerate_registry_ops.py create mode 100644 src/aipass/trigger/tests/test_branch_log_events.py create mode 100644 src/aipass/trigger/tests/test_error_reporter.py create mode 100644 src/aipass/trigger/tests/test_log_events.py create mode 100644 src/aipass/trigger/tests/test_log_watcher.py create mode 100644 src/aipass/trigger/tests/test_medic_state.py create mode 100644 src/aipass/trigger/tests/test_watchers_log_watcher.py create mode 100644 src/commons/tests/test_comments_posts.py create mode 100644 src/commons/tests/test_curation_explore_welcome_ops.py create mode 100644 src/commons/tests/test_notification_ops.py create mode 100644 src/skills/tests/test_creator.py create mode 100644 src/skills/tests/test_creator_handler.py create mode 100644 src/skills/tests/test_template.py diff --git a/src/aipass/ai_mail/tests/test_branch_ping.py b/src/aipass/ai_mail/tests/test_branch_ping.py new file mode 100644 index 00000000..9903ee93 --- /dev/null +++ b/src/aipass/ai_mail/tests/test_branch_ping.py @@ -0,0 +1,263 @@ +# =================== AIPass ==================== +# Name: test_branch_ping.py +# Description: Tests for branch ping orchestration module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for branch_ping module -- command routing and orchestration.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +import aipass.ai_mail.apps.modules.branch_ping as mod + + +# --- Fixtures -------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _suppress_log_operation(monkeypatch): + """Prevent json_handler.log_operation from touching real files.""" + monkeypatch.setattr(mod, "json_handler", MagicMock()) + + +@pytest.fixture(autouse=True) +def _suppress_logger(monkeypatch): + """Suppress logger output during tests.""" + monkeypatch.setattr(mod, "logger", MagicMock()) + + +@pytest.fixture(autouse=True) +def _mock_console(monkeypatch): + """Mock console and error to prevent real rich output.""" + mock_console = MagicMock() + monkeypatch.setattr(mod, "console", mock_console) + monkeypatch.setattr(mod, "error", MagicMock()) + return mock_console + + +# --- handle_command tests --------------------------------------------- + + +def test_handle_command_returns_false_for_unknown_command(): + """Returns False for commands not handled by this module.""" + assert mod.handle_command("unknown_cmd", ["run"]) is False + + +def test_handle_command_returns_false_for_empty_unknown(): + """Returns False for another unrecognized command.""" + assert mod.handle_command("deploy", []) is False + + +def test_handle_command_routes_ping(monkeypatch): + """Routes 'ping' command to handle_ping.""" + mock_ping = MagicMock(return_value=True) + monkeypatch.setattr(mod, "handle_ping", mock_ping) + + result = mod.handle_command("ping", ["run"]) + assert result is True + mock_ping.assert_called_once() + + +def test_handle_command_routes_ping_with_verbose(monkeypatch): + """Routes 'ping' with --verbose flag.""" + mock_ping = MagicMock(return_value=True) + monkeypatch.setattr(mod, "handle_ping", mock_ping) + + mod.handle_command("ping", ["--verbose"]) + mock_ping.assert_called_once_with(True) + + +def test_handle_command_routes_status(monkeypatch): + """Routes 'status' command to handle_status.""" + mock_status = MagicMock(return_value=True) + monkeypatch.setattr(mod, "handle_status", mock_status) + + result = mod.handle_command("status", ["run"]) + assert result is True + mock_status.assert_called_once() + + +def test_handle_command_routes_registry(monkeypatch): + """Routes 'registry' command to handle_registry.""" + mock_registry = MagicMock(return_value=True) + monkeypatch.setattr(mod, "handle_registry", mock_registry) + + result = mod.handle_command("registry", ["run"]) + assert result is True + mock_registry.assert_called_once() + + +def test_handle_command_routes_thresholds(): + """Routes 'thresholds' command to handle_thresholds.""" + result = mod.handle_command("thresholds", ["run"]) + assert result is True + + +def test_handle_command_help_flag(monkeypatch): + """--help flag calls print_help and returns True.""" + mock_help = MagicMock() + monkeypatch.setattr(mod, "print_help", mock_help) + + result = mod.handle_command("ping", ["--help"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_h_flag(monkeypatch): + """-h flag calls print_help and returns True.""" + mock_help = MagicMock() + monkeypatch.setattr(mod, "print_help", mock_help) + + result = mod.handle_command("status", ["-h"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_help_word(monkeypatch): + """'help' as first arg calls print_help and returns True.""" + mock_help = MagicMock() + monkeypatch.setattr(mod, "print_help", mock_help) + + result = mod.handle_command("registry", ["help"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_no_args_calls_introspection(monkeypatch): + """No args calls print_introspection and returns True.""" + mock_introspect = MagicMock() + monkeypatch.setattr(mod, "print_introspection", mock_introspect) + + result = mod.handle_command("ping", []) + assert result is True + mock_introspect.assert_called_once() + + +# --- handle_thresholds tests ------------------------------------------ + + +def test_handle_thresholds_returns_true(): + """handle_thresholds returns True.""" + assert mod.handle_thresholds() is True + + +def test_handle_thresholds_prints_values(_mock_console): + """handle_thresholds prints threshold range values.""" + mod.handle_thresholds() + + printed = " ".join(str(c) for c in _mock_console.print.call_args_list) + assert "400" in printed + assert "401" in printed + assert "550" in printed + assert "551" in printed + + +# --- handle_ping tests ------------------------------------------------ + + +def test_handle_ping_returns_true_on_success(monkeypatch): + """handle_ping returns True when all handlers succeed.""" + monkeypatch.setattr(mod, "get_branch_context", lambda: ("test-branch", Path("/tmp/test"))) + monkeypatch.setattr(mod, "count_file_lines", lambda f: 100) + monkeypatch.setattr(mod, "get_status_from_count", lambda c: "green") + monkeypatch.setattr(mod, "update_json_memory_health", MagicMock()) + monkeypatch.setattr(mod, "ping_registry", MagicMock()) + + assert mod.handle_ping(verbose=False) is True + + +def test_handle_ping_verbose_prints_output(monkeypatch, _mock_console): + """handle_ping with verbose=True prints status info.""" + monkeypatch.setattr(mod, "get_branch_context", lambda: ("my-branch", Path("/tmp/test"))) + monkeypatch.setattr(mod, "count_file_lines", lambda f: 250) + monkeypatch.setattr(mod, "get_status_from_count", lambda c: "yellow") + monkeypatch.setattr(mod, "update_json_memory_health", MagicMock()) + monkeypatch.setattr(mod, "ping_registry", MagicMock()) + + mod.handle_ping(verbose=True) + + printed = " ".join(str(c) for c in _mock_console.print.call_args_list) + assert "my-branch" in printed + assert "250" in printed + assert "yellow" in printed + + +def test_handle_ping_returns_false_on_exception(monkeypatch): + """handle_ping returns False when get_branch_context raises.""" + def raise_error(): + raise RuntimeError("git not found") + + monkeypatch.setattr(mod, "get_branch_context", raise_error) + + assert mod.handle_ping(verbose=False) is False + + +def test_handle_ping_calls_ping_registry(monkeypatch): + """handle_ping calls ping_registry with correct arguments.""" + mock_ping_reg = MagicMock() + monkeypatch.setattr(mod, "get_branch_context", lambda: ("feat-x", Path("/tmp/feat"))) + monkeypatch.setattr(mod, "count_file_lines", lambda f: 50) + monkeypatch.setattr(mod, "get_status_from_count", lambda c: "green") + monkeypatch.setattr(mod, "update_json_memory_health", MagicMock()) + monkeypatch.setattr(mod, "ping_registry", mock_ping_reg) + + mod.handle_ping(verbose=False) + + mock_ping_reg.assert_called_once_with( + "feat-x", + Path("/tmp/feat"), + {"line_count": 50, "status": "green"}, + {"line_count": 50, "status": "green"}, + ) + + +# --- handle_status tests --------------------------------------------- + + +def test_handle_status_returns_true_on_success(monkeypatch): + """handle_status returns True when handlers succeed.""" + monkeypatch.setattr(mod, "get_branch_context", lambda: ("main", Path("/tmp/main"))) + monkeypatch.setattr(mod, "get_health_info", lambda f: {"line_count": 100, "status": "green"}) + + assert mod.handle_status() is True + + +def test_handle_status_returns_false_on_exception(monkeypatch): + """handle_status returns False when an exception is raised.""" + def raise_error(): + raise RuntimeError("file not found") + + monkeypatch.setattr(mod, "get_branch_context", raise_error) + + assert mod.handle_status() is False + + +# --- print_introspection tests ---------------------------------------- + + +def test_print_introspection_prints_module_info(_mock_console): + """print_introspection prints module name and handler info.""" + mod.print_introspection() + + printed = " ".join(str(c) for c in _mock_console.print.call_args_list) + assert "branch_ping" in printed + assert "handlers/monitoring/" in printed + assert "handlers/registry/" in printed + + +# --- print_help tests ------------------------------------------------- + + +def test_print_help_prints_command_list(_mock_console): + """print_help prints available commands.""" + mod.print_help() + + printed = " ".join(str(c) for c in _mock_console.print.call_args_list) + assert "ping" in printed + assert "status" in printed + assert "registry" in printed + assert "thresholds" in printed diff --git a/src/aipass/ai_mail/tests/test_central_writer.py b/src/aipass/ai_mail/tests/test_central_writer.py new file mode 100644 index 00000000..5fd4a866 --- /dev/null +++ b/src/aipass/ai_mail/tests/test_central_writer.py @@ -0,0 +1,359 @@ +# =================== AIPass ==================== +# Name: test_central_writer.py +# Description: Tests for central_writer -- branch inbox aggregation and central file writing +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for central_writer -- inbox stats aggregation, central file output.""" + +import json +import pytest +from pathlib import Path +from unittest.mock import MagicMock + +import aipass.ai_mail.apps.handlers.central_writer as mod + + +# --- Fixtures -------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _suppress_json_handler(monkeypatch): + """Prevent json_handler from touching real files.""" + monkeypatch.setattr(mod, "json_handler", MagicMock()) + + +@pytest.fixture(autouse=True) +def _suppress_logger(monkeypatch): + """Suppress logger output during tests.""" + monkeypatch.setattr(mod, "logger", MagicMock()) + + +# --- extract_branch_name tests ---------------------------------------- + + +def test_extract_branch_name_standard_path(): + """Standard .ai_mail.local path extracts uppercase branch name.""" + inbox = Path("/repo/src/aipass/seedgo/.ai_mail.local/inbox.json") + assert mod.extract_branch_name(inbox) == "SEEDGO" + + +def test_extract_branch_name_nested_path(): + """Deeply nested path still extracts the immediate parent of .ai_mail.local.""" + inbox = Path("/repo/src/aipass/deep/nested/drone/.ai_mail.local/inbox.json") + assert mod.extract_branch_name(inbox) == "DRONE" + + +def test_extract_branch_name_lowercase_dir(): + """Lowercase directory name is uppercased.""" + inbox = Path("/tmp/prax/.ai_mail.local/inbox.json") + assert mod.extract_branch_name(inbox) == "PRAX" + + +# --- read_inbox_stats tests ------------------------------------------- + + +def test_read_inbox_stats_valid_inbox(tmp_path): + """Returns correct (unread, total) tuple from valid inbox.json.""" + inbox = tmp_path / "inbox.json" + inbox.write_text( + json.dumps({"unread_count": 3, "total_messages": 10}), + encoding="utf-8", + ) + assert mod.read_inbox_stats(inbox) == (3, 10) + + +def test_read_inbox_stats_defaults_missing_fields(tmp_path): + """Defaults to 0 when unread_count or total_messages fields are absent.""" + inbox = tmp_path / "inbox.json" + inbox.write_text(json.dumps({"other_field": "value"}), encoding="utf-8") + assert mod.read_inbox_stats(inbox) == (0, 0) + + +def test_read_inbox_stats_partial_fields(tmp_path): + """One field present, one absent -- present field used, absent defaults to 0.""" + inbox = tmp_path / "inbox.json" + inbox.write_text(json.dumps({"unread_count": 7}), encoding="utf-8") + assert mod.read_inbox_stats(inbox) == (7, 0) + + +# --- calculate_system_totals tests ------------------------------------ + + +def test_calculate_system_totals_multiple_branches(): + """Correct sums across multiple branches.""" + branch_stats = { + "SEEDGO": {"unread": 5, "total": 8}, + "DRONE": {"unread": 2, "total": 3}, + "PRAX": {"unread": 0, "total": 4}, + } + result = mod.calculate_system_totals(branch_stats) + assert result == {"total_unread": 7, "total_messages": 15} + + +def test_calculate_system_totals_empty_dict(): + """Empty dict gives zero totals.""" + result = mod.calculate_system_totals({}) + assert result == {"total_unread": 0, "total_messages": 0} + + +# --- build_central_data tests ----------------------------------------- + + +def test_build_central_data_has_required_keys(): + """Output contains service, last_updated, branch_stats, system_totals.""" + branch_stats = {"SEEDGO": {"unread": 1, "total": 2}} + result = mod.build_central_data(branch_stats) + assert set(result.keys()) == {"service", "last_updated", "branch_stats", "system_totals"} + + +def test_build_central_data_service_is_ai_mail(): + """Service field is 'ai_mail'.""" + result = mod.build_central_data({}) + assert result["service"] == "ai_mail" + + +def test_build_central_data_includes_totals(): + """system_totals reflects aggregated branch_stats.""" + branch_stats = { + "A": {"unread": 1, "total": 5}, + "B": {"unread": 3, "total": 7}, + } + result = mod.build_central_data(branch_stats) + assert result["system_totals"]["total_unread"] == 4 + assert result["system_totals"]["total_messages"] == 12 + + +# --- find_all_inbox_files tests ---------------------------------------- + + +def test_find_all_inbox_files_discovers_inboxes(tmp_path, monkeypatch): + """Finds inbox.json files inside .ai_mail.local directories.""" + monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) + + # Create two branch inbox structures + for branch in ("seedgo", "drone"): + mail_dir = tmp_path / "src" / "aipass" / branch / ".ai_mail.local" + mail_dir.mkdir(parents=True) + (mail_dir / "inbox.json").write_text("{}", encoding="utf-8") + + result = mod.find_all_inbox_files() + assert len(result) == 2 + names = {p.parent.parent.name for p in result} + assert names == {"seedgo", "drone"} + + +def test_find_all_inbox_files_skips_archive(tmp_path, monkeypatch): + """Skips .ai_mail.local dirs inside .archive paths.""" + monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) + + # Valid inbox + valid = tmp_path / "branch" / ".ai_mail.local" + valid.mkdir(parents=True) + (valid / "inbox.json").write_text("{}", encoding="utf-8") + + # Archived inbox -- should be skipped + archived = tmp_path / ".archive" / "old" / ".ai_mail.local" + archived.mkdir(parents=True) + (archived / "inbox.json").write_text("{}", encoding="utf-8") + + result = mod.find_all_inbox_files() + assert len(result) == 1 + + +def test_find_all_inbox_files_skips_backup(tmp_path, monkeypatch): + """Skips .ai_mail.local dirs inside .backup paths.""" + monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) + + # Valid inbox + valid = tmp_path / "branch" / ".ai_mail.local" + valid.mkdir(parents=True) + (valid / "inbox.json").write_text("{}", encoding="utf-8") + + # Backup inbox -- should be skipped + backup = tmp_path / ".backup" / "snap" / ".ai_mail.local" + backup.mkdir(parents=True) + (backup / "inbox.json").write_text("{}", encoding="utf-8") + + result = mod.find_all_inbox_files() + assert len(result) == 1 + + +def test_find_all_inbox_files_skips_backups_dir(tmp_path, monkeypatch): + """Skips .ai_mail.local dirs inside /backups/ paths.""" + monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) + + valid = tmp_path / "branch" / ".ai_mail.local" + valid.mkdir(parents=True) + (valid / "inbox.json").write_text("{}", encoding="utf-8") + + backup = tmp_path / "backups" / "snap" / ".ai_mail.local" + backup.mkdir(parents=True) + (backup / "inbox.json").write_text("{}", encoding="utf-8") + + result = mod.find_all_inbox_files() + assert len(result) == 1 + + +def test_find_all_inbox_files_ignores_dir_without_inbox(tmp_path, monkeypatch): + """Skips .ai_mail.local dirs that don't contain inbox.json.""" + monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) + + empty_mail = tmp_path / "branch" / ".ai_mail.local" + empty_mail.mkdir(parents=True) + # No inbox.json created + + result = mod.find_all_inbox_files() + assert len(result) == 0 + + +# --- aggregate_branch_stats tests -------------------------------------- + + +def test_aggregate_branch_stats_multiple_branches(tmp_path, monkeypatch): + """Aggregates stats from multiple branches, filtering by registry.""" + # Create inbox files + seedgo_mail = tmp_path / "seedgo" / ".ai_mail.local" + seedgo_mail.mkdir(parents=True) + (seedgo_mail / "inbox.json").write_text( + json.dumps({"unread_count": 2, "total_messages": 5}), + encoding="utf-8", + ) + + drone_mail = tmp_path / "drone" / ".ai_mail.local" + drone_mail.mkdir(parents=True) + (drone_mail / "inbox.json").write_text( + json.dumps({"unread_count": 1, "total_messages": 3}), + encoding="utf-8", + ) + + monkeypatch.setattr( + mod, "find_all_inbox_files", + lambda: [seedgo_mail / "inbox.json", drone_mail / "inbox.json"], + ) + monkeypatch.setattr( + mod, "get_valid_branch_names", + lambda: {"SEEDGO", "DRONE"}, + ) + + result = mod.aggregate_branch_stats() + assert result == { + "SEEDGO": {"unread": 2, "total": 5}, + "DRONE": {"unread": 1, "total": 3}, + } + + +def test_aggregate_branch_stats_skips_unregistered(tmp_path, monkeypatch): + """Branches not in registry are excluded from results.""" + rogue_mail = tmp_path / "rogue" / ".ai_mail.local" + rogue_mail.mkdir(parents=True) + (rogue_mail / "inbox.json").write_text( + json.dumps({"unread_count": 9, "total_messages": 20}), + encoding="utf-8", + ) + + valid_mail = tmp_path / "seedgo" / ".ai_mail.local" + valid_mail.mkdir(parents=True) + (valid_mail / "inbox.json").write_text( + json.dumps({"unread_count": 1, "total_messages": 2}), + encoding="utf-8", + ) + + monkeypatch.setattr( + mod, "find_all_inbox_files", + lambda: [rogue_mail / "inbox.json", valid_mail / "inbox.json"], + ) + monkeypatch.setattr( + mod, "get_valid_branch_names", + lambda: {"SEEDGO"}, # ROGUE not registered + ) + + result = mod.aggregate_branch_stats() + assert "ROGUE" not in result + assert "SEEDGO" in result + assert result["SEEDGO"] == {"unread": 1, "total": 2} + + +def test_aggregate_branch_stats_skips_malformed_inbox(tmp_path, monkeypatch): + """Malformed inbox.json is skipped with a warning, not a crash.""" + bad_mail = tmp_path / "bad" / ".ai_mail.local" + bad_mail.mkdir(parents=True) + (bad_mail / "inbox.json").write_text("NOT VALID JSON", encoding="utf-8") + + good_mail = tmp_path / "good" / ".ai_mail.local" + good_mail.mkdir(parents=True) + (good_mail / "inbox.json").write_text( + json.dumps({"unread_count": 1, "total_messages": 1}), + encoding="utf-8", + ) + + monkeypatch.setattr( + mod, "find_all_inbox_files", + lambda: [bad_mail / "inbox.json", good_mail / "inbox.json"], + ) + monkeypatch.setattr( + mod, "get_valid_branch_names", + lambda: {"BAD", "GOOD"}, + ) + + result = mod.aggregate_branch_stats() + assert "BAD" not in result + assert "GOOD" in result + + +# --- write_central_file tests ------------------------------------------ + + +def test_write_central_file_creates_file(tmp_path, monkeypatch): + """Creates the central file with correct JSON content.""" + central_dir = tmp_path / ".ai_central" + central_file = central_dir / "AI_MAIL.central.json" + + monkeypatch.setattr(mod, "AI_CENTRAL_DIR", central_dir) + monkeypatch.setattr(mod, "CENTRAL_FILE", central_file) + + data = { + "service": "ai_mail", + "last_updated": "2026-04-03", + "branch_stats": {"SEEDGO": {"unread": 1, "total": 2}}, + "system_totals": {"total_unread": 1, "total_messages": 2}, + } + mod.write_central_file(data) + + assert central_file.exists() + written = json.loads(central_file.read_text(encoding="utf-8")) + assert written == data + + +def test_write_central_file_creates_directory(tmp_path, monkeypatch): + """Creates the AI_CENTRAL directory if it doesn't exist.""" + central_dir = tmp_path / "new_dir" / ".ai_central" + central_file = central_dir / "AI_MAIL.central.json" + + monkeypatch.setattr(mod, "AI_CENTRAL_DIR", central_dir) + monkeypatch.setattr(mod, "CENTRAL_FILE", central_file) + + mod.write_central_file({"service": "ai_mail"}) + + assert central_dir.exists() + assert central_file.exists() + + +def test_write_central_file_overwrites_existing(tmp_path, monkeypatch): + """Overwrites an existing central file with new data.""" + central_dir = tmp_path / ".ai_central" + central_dir.mkdir(parents=True) + central_file = central_dir / "AI_MAIL.central.json" + central_file.write_text(json.dumps({"old": "data"}), encoding="utf-8") + + monkeypatch.setattr(mod, "AI_CENTRAL_DIR", central_dir) + monkeypatch.setattr(mod, "CENTRAL_FILE", central_file) + + new_data = {"service": "ai_mail", "new": True} + mod.write_central_file(new_data) + + written = json.loads(central_file.read_text(encoding="utf-8")) + assert written == new_data + assert "old" not in written diff --git a/src/aipass/ai_mail/tests/test_dispatch_monitor.py b/src/aipass/ai_mail/tests/test_dispatch_monitor.py new file mode 100644 index 00000000..f1875766 --- /dev/null +++ b/src/aipass/ai_mail/tests/test_dispatch_monitor.py @@ -0,0 +1,1154 @@ +# =================== AIPass ==================== +# Name: test_dispatch_monitor.py +# Description: Tests for dispatch monitor lifecycle handler +# Version: 1.0.0 +# Created: 2026-04-02 +# Modified: 2026-04-02 +# ============================================= + +"""Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting.""" + +import json +import subprocess +import time +import pytest +from pathlib import Path +from unittest.mock import MagicMock + +import aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor as mod +from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import ( + _check_jsonl_activity, + _check_rate_limited, + _get_jsonl_projects_dir, + _make_fresh_cmd, + _run_with_startup_check, + _send_bounce, + _snapshot_jsonl_sizes, + main, +) + + +# --- Fixtures -------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _suppress_log_operation(monkeypatch): + """Prevent json_handler.log_operation from touching real files.""" + monkeypatch.setattr( + mod, "json_handler", + MagicMock(), + ) + + +@pytest.fixture(autouse=True) +def _suppress_logger(monkeypatch): + """Suppress logger output during tests.""" + monkeypatch.setattr(mod, "logger", MagicMock()) + + +@pytest.fixture +def stderr_log(tmp_path): + """Create a stderr log file and return its path string.""" + log_file = tmp_path / "stderr.log" + log_file.write_text("", encoding="utf-8") + return str(log_file) + + +@pytest.fixture +def lock_file(tmp_path): + """Create a lock file structure and return the lock path string.""" + # Lock lives at branch_path/.ai_mail.local/.dispatch.lock + ai_mail_dir = tmp_path / "branch" / ".ai_mail.local" + ai_mail_dir.mkdir(parents=True) + lock = ai_mail_dir / ".dispatch.lock" + lock.write_text("{}", encoding="utf-8") + return str(lock) + + +# --- _check_rate_limited tests ---------------------------------------- + + +def test_check_rate_limited_429(tmp_path): + """Returns True when stderr contains '429'.""" + log = tmp_path / "stderr.log" + log.write_text("Error: API returned 429 Too Many Requests", encoding="utf-8") + assert _check_rate_limited(str(log)) is True + + +def test_check_rate_limited_rate_limit(tmp_path): + """Returns True when stderr contains 'rate_limit'.""" + log = tmp_path / "stderr.log" + log.write_text("error: rate_limit exceeded", encoding="utf-8") + assert _check_rate_limited(str(log)) is True + + +def test_check_rate_limited_overloaded(tmp_path): + """Returns True when stderr contains 'overloaded'.""" + log = tmp_path / "stderr.log" + log.write_text("API is overloaded, please retry", encoding="utf-8") + assert _check_rate_limited(str(log)) is True + + +def test_check_rate_limited_529(tmp_path): + """Returns True when stderr contains '529'.""" + log = tmp_path / "stderr.log" + log.write_text("HTTP 529 Service Unavailable", encoding="utf-8") + assert _check_rate_limited(str(log)) is True + + +def test_check_rate_limited_normal_content(tmp_path): + """Returns False for normal stderr content.""" + log = tmp_path / "stderr.log" + log.write_text("Starting agent...\nProcessing task\nDone", encoding="utf-8") + assert _check_rate_limited(str(log)) is False + + +def test_check_rate_limited_missing_file(tmp_path): + """Returns False when file doesn't exist.""" + assert _check_rate_limited(str(tmp_path / "nonexistent.log")) is False + + +# --- _make_fresh_cmd tests -------------------------------------------- + + +def test_make_fresh_cmd_removes_c_flag(): + """Removes -c flag from command.""" + cmd = ["claude", "-c", "--model", "opus"] + result = _make_fresh_cmd(cmd) + assert result == ["claude", "--model", "opus"] + + +def test_make_fresh_cmd_no_c_flag(): + """Returns same command if no -c flag.""" + cmd = ["claude", "--model", "opus"] + result = _make_fresh_cmd(cmd) + assert result == ["claude", "--model", "opus"] + + +def test_make_fresh_cmd_does_not_remove_c_value(): + """Doesn't remove -c from positions where it's a standalone flag.""" + # _make_fresh_cmd removes all standalone "-c" args. If -c only appears + # as the flag itself, it gets removed. Other args containing "c" are kept. + cmd = ["claude", "-c", "--config", "c_file.json"] + result = _make_fresh_cmd(cmd) + assert result == ["claude", "--config", "c_file.json"] + assert "-c" not in result + + +# --- _run_with_startup_check tests (mock Popen) ----------------------- + + +def test_run_startup_check_success(tmp_path, monkeypatch): + """JSONL activity detected within timeout, process exits 0.""" + monkeypatch.setattr(mod, "STARTUP_TIMEOUT", 0.5) + monkeypatch.setattr(mod, "POLL_INTERVAL", 0.05) + monkeypatch.setattr(mod, "HARD_TIMEOUT", 5) + + stdout_log = str(tmp_path / "stdout.log") + stderr_fh = MagicMock() + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_proc.returncode = 0 + mock_proc.wait = MagicMock(return_value=0) + + monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **kw: mock_proc) + # Simulate JSONL activity on first check + activity_calls = [0] + + def fake_activity(projects_dir, initial_sizes): + activity_calls[0] += 1 + return activity_calls[0] >= 1 # Active from first call + + monkeypatch.setattr(mod, "_get_jsonl_projects_dir", lambda cwd: tmp_path / "projects") + monkeypatch.setattr(mod, "_snapshot_jsonl_sizes", lambda d: {}) + monkeypatch.setattr(mod, "_check_jsonl_activity", fake_activity) + + exit_code, startup_failed = _run_with_startup_check( + ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" + ) + assert exit_code == 0 + assert startup_failed is False + + +def test_run_startup_check_timeout(tmp_path, monkeypatch): + """Process produces no stdout, gets killed after STARTUP_TIMEOUT.""" + monkeypatch.setattr(mod, "STARTUP_TIMEOUT", 0.1) + monkeypatch.setattr(mod, "POLL_INTERVAL", 0.02) + monkeypatch.setattr(mod, "HARD_TIMEOUT", 5) + + stdout_log = str(tmp_path / "stdout.log") + stderr_fh = MagicMock() + + mock_proc = MagicMock() + mock_proc.poll.return_value = None # Never exits on its own + mock_proc.returncode = None + mock_proc.wait.return_value = None + mock_proc.terminate = MagicMock() + + monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **kw: mock_proc) + mock_kill = MagicMock() + monkeypatch.setattr(mod, "_kill_process", mock_kill) + + exit_code, startup_failed = _run_with_startup_check( + ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" + ) + assert exit_code == -3 + assert startup_failed is True + mock_kill.assert_called_once() + + +def test_run_startup_check_process_exits_during_startup_no_output(tmp_path, monkeypatch): + """Process exits during startup with zero output — IS a startup failure.""" + monkeypatch.setattr(mod, "STARTUP_TIMEOUT", 0.5) + monkeypatch.setattr(mod, "POLL_INTERVAL", 0.02) + monkeypatch.setattr(mod, "HARD_TIMEOUT", 5) + + stdout_log = str(tmp_path / "stdout.log") + stderr_fh = MagicMock() + + mock_proc = MagicMock() + mock_proc.poll.return_value = 1 # Already exited with error + mock_proc.returncode = 1 + + monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **kw: mock_proc) + + exit_code, startup_failed = _run_with_startup_check( + ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" + ) + assert exit_code == 1 + assert startup_failed is True # Zero output = startup failure + + +def test_run_startup_check_process_exits_during_startup_with_output(tmp_path, monkeypatch): + """Process exits during startup WITH JSONL activity — NOT a startup failure.""" + monkeypatch.setattr(mod, "STARTUP_TIMEOUT", 0.5) + monkeypatch.setattr(mod, "POLL_INTERVAL", 0.02) + monkeypatch.setattr(mod, "HARD_TIMEOUT", 5) + + stdout_log = str(tmp_path / "stdout.log") + stderr_fh = MagicMock() + + mock_proc = MagicMock() + poll_calls = [0] + + def fake_poll(): + poll_calls[0] += 1 + if poll_calls[0] <= 1: + return None + # Second poll: process has exited + return 1 + + mock_proc.poll = fake_poll + mock_proc.returncode = 1 + mock_proc.wait = MagicMock(return_value=1) + + monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **kw: mock_proc) + # Simulate JSONL activity so started=True + monkeypatch.setattr(mod, "_get_jsonl_projects_dir", lambda cwd: tmp_path / "projects") + monkeypatch.setattr(mod, "_snapshot_jsonl_sizes", lambda d: {}) + monkeypatch.setattr(mod, "_check_jsonl_activity", lambda d, s: True) + + exit_code, startup_failed = _run_with_startup_check( + ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" + ) + assert exit_code == 1 + assert startup_failed is False # Had JSONL activity = normal failure, not startup + + +def test_run_startup_check_hard_timeout(tmp_path, monkeypatch): + """Process starts but runs past HARD_TIMEOUT.""" + monkeypatch.setattr(mod, "STARTUP_TIMEOUT", 0.5) + monkeypatch.setattr(mod, "POLL_INTERVAL", 0.02) + monkeypatch.setattr(mod, "HARD_TIMEOUT", 0.1) + + stdout_log = str(tmp_path / "stdout.log") + stderr_fh = MagicMock() + + mock_proc = MagicMock() + poll_calls = [0] + + def fake_poll(): + poll_calls[0] += 1 + if poll_calls[0] == 1: + Path(stdout_log).write_text("output", encoding="utf-8") + return None + return None + + mock_proc.poll = fake_poll + mock_proc.returncode = None + mock_proc.wait.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=0.1) + + monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **kw: mock_proc) + mock_kill = MagicMock() + monkeypatch.setattr(mod, "_kill_process", mock_kill) + # Simulate JSONL activity so startup succeeds and we reach the hard timeout + monkeypatch.setattr(mod, "_get_jsonl_projects_dir", lambda cwd: tmp_path / "projects") + monkeypatch.setattr(mod, "_snapshot_jsonl_sizes", lambda d: {}) + monkeypatch.setattr(mod, "_check_jsonl_activity", lambda d, s: True) + + exit_code, startup_failed = _run_with_startup_check( + ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" + ) + assert exit_code == -1 + assert startup_failed is False + mock_kill.assert_called_once() + + +def test_run_startup_check_spawn_failure(tmp_path, monkeypatch): + """Popen raises exception, returns (-2, False).""" + monkeypatch.setattr(mod, "STARTUP_TIMEOUT", 0.1) + monkeypatch.setattr(mod, "POLL_INTERVAL", 0.02) + + stdout_log = str(tmp_path / "stdout.log") + stderr_fh = MagicMock() + + def raise_oserror(*a, **kw): + raise OSError("spawn failed") + + monkeypatch.setattr(mod.subprocess, "Popen", raise_oserror) + + exit_code, startup_failed = _run_with_startup_check( + ["claude"], stdout_log, stderr_fh, str(tmp_path), {}, "@test" + ) + assert exit_code == -2 + assert startup_failed is False + + +# --- Retry loop in main() tests -------------------------------------- + + +@pytest.fixture +def main_argv(tmp_path): + """Build sys.argv and supporting files for main() tests.""" + branch_dir = tmp_path / "branch" + ai_mail_dir = branch_dir / ".ai_mail.local" + ai_mail_dir.mkdir(parents=True) + logs_dir = branch_dir / "logs" + logs_dir.mkdir(parents=True) + + lock_file = ai_mail_dir / ".dispatch.lock" + lock_file.write_text("{}", encoding="utf-8") + + stderr_log = tmp_path / "stderr.log" + stderr_log.write_text("", encoding="utf-8") + + argv = [ + "dispatch_monitor.py", + "@test_branch", + str(lock_file), + "@sender", + str(stderr_log), + "--", + "claude", "-c", "--model", "opus", + ] + return argv, lock_file, stderr_log + + +def test_main_single_attempt_success(monkeypatch, main_argv): + """First attempt succeeds, no retries.""" + argv, lock_file, stderr_log = main_argv + + mock_run = MagicMock(return_value=(0, False)) + mock_bounce = MagicMock() + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", mock_run) + monkeypatch.setattr(mod, "_send_bounce", mock_bounce) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + mock_run.assert_called_once() + mock_bounce.assert_not_called() + + +def test_main_second_attempt_success(monkeypatch, main_argv): + """First fails, second succeeds.""" + argv, lock_file, stderr_log = main_argv + + mock_run = MagicMock(side_effect=[(1, False), (0, False)]) + mock_bounce = MagicMock() + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", mock_run) + monkeypatch.setattr(mod, "_send_bounce", mock_bounce) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr(mod, "time", MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + )) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert mock_run.call_count == 2 + mock_bounce.assert_not_called() + + +def test_main_third_attempt_fresh(monkeypatch, main_argv): + """Third attempt removes -c flag (fresh start).""" + argv, lock_file, stderr_log = main_argv + + calls: list[list[str]] = [] + + def track_run(cmd, *args, **kwargs): + calls.append(cmd) + return (1, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", track_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr(mod, "time", MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + )) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + assert len(calls) == 3 + # Attempts 1 and 2 should have -c + assert "-c" in calls[0] + assert "-c" in calls[1] + # Attempt 3 should NOT have -c (fresh) + assert "-c" not in calls[2] + + +def test_main_all_three_fail_sends_bounce(monkeypatch, main_argv): + """All 3 fail: bounce is sent with attempt details.""" + argv, lock_file, stderr_log = main_argv + + mock_bounce = MagicMock() + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", + MagicMock(side_effect=[(1, False), (-3, True), (1, False)])) + monkeypatch.setattr(mod, "_send_bounce", mock_bounce) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr(mod, "time", MagicMock( + time=time.time, + strftime=time.strftime, + sleep=MagicMock(), + )) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + mock_bounce.assert_called_once() + reason = mock_bounce.call_args[0][1] + assert "3 attempts" in reason + + +def test_main_rate_limit_delay(monkeypatch, main_argv): + """When _check_rate_limited returns True, verify delay happens.""" + argv, lock_file, stderr_log = main_argv + + mock_time = MagicMock() + mock_time.time = time.time + mock_time.strftime = time.strftime + mock_time.sleep = MagicMock() + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", + MagicMock(side_effect=[(1, False), (0, False)])) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=True)) + monkeypatch.setattr(mod, "time", mock_time) + monkeypatch.setattr(mod, "RATE_LIMIT_DELAY", 30) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + # Verify rate limit delay was used (30s, not 5s) + mock_time.sleep.assert_called_with(30) + + +# --- _send_bounce tests ----------------------------------------------- + + +def test_send_bounce_success(tmp_path, monkeypatch): + """Successful bounce sends email via drone subprocess.""" + stderr_log = tmp_path / "stderr.log" + stderr_log.write_text("some error output\nmore lines\n", encoding="utf-8") + + lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock" + lock.parent.mkdir(parents=True) + lock.write_text("{}", encoding="utf-8") + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_sub_run = MagicMock(return_value=mock_result) + monkeypatch.setattr(mod.subprocess, "run", mock_sub_run) + + result = _send_bounce("@test", "failed", "@sender", str(lock), str(stderr_log)) + assert result is True + mock_sub_run.assert_called_once() + + +def test_send_bounce_falls_back_to_file(tmp_path, monkeypatch): + """Failed drone send falls back to bounce file.""" + stderr_log = tmp_path / "stderr.log" + stderr_log.write_text("error output\n", encoding="utf-8") + + lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock" + lock.parent.mkdir(parents=True) + lock.write_text("{}", encoding="utf-8") + + def raise_error(*a, **kw): + raise subprocess.SubprocessError("drone failed") + + monkeypatch.setattr(mod.subprocess, "run", raise_error) + + result = _send_bounce("@test", "failed", "@sender", str(lock), str(stderr_log)) + assert result is False + + bounce_file = lock.parent / "last_bounce.json" + assert bounce_file.exists() + data = json.loads(bounce_file.read_text(encoding="utf-8")) + assert data["branch"] == "@test" + assert data["reason"] == "failed" + + +def test_send_bounce_missing_stderr(tmp_path, monkeypatch): + """Missing stderr log handled gracefully.""" + lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock" + lock.parent.mkdir(parents=True) + lock.write_text("{}", encoding="utf-8") + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_sub_run = MagicMock(return_value=mock_result) + monkeypatch.setattr(mod.subprocess, "run", mock_sub_run) + + # Pass a nonexistent stderr log + result = _send_bounce("@test", "failed", "@sender", str(lock), + str(tmp_path / "nonexistent.log")) + assert result is True + # The body should contain "(no stderr captured)" fallback + call_args = mock_sub_run.call_args + body = call_args[0][0][5] # ["drone", "@ai_mail", "send", sender, subject, body] + assert "no stderr captured" in body + + +# --- Notification naming test ------------------------------------------ + + +def test_notification_uses_at_branch_format(monkeypatch, main_argv): + """Notification title uses '@branch_name status' format.""" + argv, lock_file, stderr_log = main_argv + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", + MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + mock_notify = MagicMock() + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.notify.send_notification", + mock_notify, + ) + + with pytest.raises(SystemExit): + main() + + mock_notify.assert_called_once() + title = mock_notify.call_args[0][0] + assert title.startswith("@test_branch") + assert "completed" in title + + +# --- _kill_process tests ----------------------------------------------- + +from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import _kill_process + + +def test_kill_process_terminate_succeeds(): + """SIGTERM succeeds within 10s — no SIGKILL needed.""" + mock_proc = MagicMock() + mock_proc.terminate = MagicMock() + mock_proc.wait = MagicMock(return_value=None) + mock_proc.kill = MagicMock() + + _kill_process(mock_proc, "@test") + + mock_proc.terminate.assert_called_once() + mock_proc.wait.assert_called_once_with(timeout=10) + mock_proc.kill.assert_not_called() + + +def test_kill_process_terminate_timeout_falls_back_to_sigkill(): + """SIGTERM times out — falls back to SIGKILL.""" + mock_proc = MagicMock() + mock_proc.terminate = MagicMock() + mock_proc.wait = MagicMock( + side_effect=[subprocess.TimeoutExpired(cmd="claude", timeout=10), None] + ) + mock_proc.kill = MagicMock() + + _kill_process(mock_proc, "@test") + + mock_proc.terminate.assert_called_once() + mock_proc.kill.assert_called_once() + + +# --- Max-turns detection tests ----------------------------------------- + + +def test_max_turns_changes_notification_status(monkeypatch, main_argv): + """stdout containing stop_reason:max_turns changes status even with exit_code==0.""" + argv, lock_file, stderr_log = main_argv + + # Write max_turns to stdout log + stdout_log = Path(str(lock_file)).parent.parent / "logs" / "dispatch_stdout.log" + stdout_log.parent.mkdir(parents=True, exist_ok=True) + + def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch): + # Simulate writing max_turns output + stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8") + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", fake_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + mock_notify = MagicMock() + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.notify.send_notification", + mock_notify, + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 # Claude exited 0 + mock_notify.assert_called_once() + title = mock_notify.call_args[0][0] + assert "MAX TURNS" in title # But notification shows max turns + + +# --- Log rotation tests ------------------------------------------------ + + +def test_stderr_rotation_on_large_file(tmp_path, monkeypatch): + """stderr > 512KB triggers rotation to .log.1 before opening.""" + stderr_log = tmp_path / "stderr.log" + # Write > 512KB to trigger rotation + stderr_log.write_text("x" * 520_000, encoding="utf-8") + + argv = [ + "dispatch_monitor.py", "@test", str(tmp_path / ".dispatch.lock"), + "@sender", str(stderr_log), "--", "claude", + ] + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=tmp_path), + ) + + # Create required dirs + (tmp_path / ".ai_mail.local").mkdir(exist_ok=True) + (tmp_path / "logs").mkdir(exist_ok=True) + lock = tmp_path / ".ai_mail.local" / ".dispatch.lock" + lock.write_text("{}", encoding="utf-8") + argv[2] = str(lock) + monkeypatch.setattr("sys.argv", argv) + + with pytest.raises(SystemExit): + main() + + # Rotated file should exist + rotated = tmp_path / "stderr.log.1" + assert rotated.exists() + assert rotated.stat().st_size >= 520_000 + + +def test_stdout_rotation_on_large_file(tmp_path, monkeypatch): + """stdout > 512KB triggers rotation to .log.1 before first attempt.""" + branch_dir = tmp_path / "branch" + ai_mail_dir = branch_dir / ".ai_mail.local" + ai_mail_dir.mkdir(parents=True) + logs_dir = branch_dir / "logs" + logs_dir.mkdir(parents=True) + + lock = ai_mail_dir / ".dispatch.lock" + lock.write_text("{}", encoding="utf-8") + stderr_log = tmp_path / "stderr.log" + stderr_log.write_text("", encoding="utf-8") + + # Write large stdout log + stdout_log = logs_dir / "dispatch_stdout.log" + stdout_log.write_text("x" * 520_000, encoding="utf-8") + + argv = [ + "dispatch_monitor.py", "@test", str(lock), + "@sender", str(stderr_log), "--", "claude", + ] + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=tmp_path), + ) + + with pytest.raises(SystemExit): + main() + + rotated = logs_dir / "dispatch_stdout.log.1" + assert rotated.exists() + + +# --- Lock file cleanup tests ------------------------------------------ + + +def test_lock_cleanup_on_success(monkeypatch, main_argv): + """Lock is deleted on successful exit.""" + argv, lock_file, stderr_log = main_argv + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert not lock_file.exists() + + +def test_lock_cleanup_on_failure(monkeypatch, main_argv): + """Lock is deleted even after all attempts fail (bounce path).""" + argv, lock_file, stderr_log = main_argv + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", + MagicMock(side_effect=[(1, False), (1, False), (1, False)])) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr(mod, "time", MagicMock( + time=time.time, strftime=time.strftime, sleep=MagicMock(), + )) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert not lock_file.exists() + + +# --- Environment variable setup tests --------------------------------- + + +def test_env_vars_set_correctly(monkeypatch, main_argv): + """Verify AIPASS_SPAWNED, SESSION_TYPE, BRANCH_NAME set; CLAUDE* stripped; venv on PATH.""" + argv, lock_file, stderr_log = main_argv + + captured_env = {} + + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch): + captured_env.update(env) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + + fake_repo = Path("/fake/repo") + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=fake_repo), + ) + + # Set a CLAUDE var that should be stripped + monkeypatch.setenv("CLAUDE_TEST_VAR", "should_be_stripped") + monkeypatch.setenv("AIPASS_BOT_ID", "should_be_stripped_too") + monkeypatch.setenv("AIPASS_CALLER_BRANCH", "@old_caller") + monkeypatch.setenv("AIPASS_CALLER_CWD", "/old/cwd") + + with pytest.raises(SystemExit): + main() + + assert captured_env["AIPASS_SPAWNED"] == "1" + assert captured_env["AIPASS_SESSION_TYPE"] == "dispatched" + assert captured_env["AIPASS_BRANCH_NAME"] == "test_branch" + assert "CLAUDE_TEST_VAR" not in captured_env + assert "AIPASS_BOT_ID" not in captured_env + assert "AIPASS_CALLER_BRANCH" not in captured_env + assert "AIPASS_CALLER_CWD" not in captured_env + # Venv bin should be on PATH + assert "/fake/repo/.venv/bin" in captured_env.get("PATH", "") + + +# === Additional tests (added 2026-04-03) =================================== + + +# --- _kill_process tests (named per spec) ---------------------------------- + + +def test_kill_process_sigterm_success(): + """terminate() succeeds within timeout — no SIGKILL needed.""" + mock_proc = MagicMock() + mock_proc.terminate = MagicMock() + mock_proc.wait = MagicMock(return_value=None) + mock_proc.kill = MagicMock() + + _kill_process(mock_proc, "@test") + + mock_proc.terminate.assert_called_once() + mock_proc.wait.assert_called_once_with(timeout=10) + mock_proc.kill.assert_not_called() + + +def test_kill_process_sigkill_fallback(): + """terminate() times out — falls back to kill().""" + mock_proc = MagicMock() + mock_proc.terminate = MagicMock() + mock_proc.wait = MagicMock( + side_effect=[subprocess.TimeoutExpired(cmd="claude", timeout=10), None] + ) + mock_proc.kill = MagicMock() + + _kill_process(mock_proc, "@test") + + mock_proc.terminate.assert_called_once() + mock_proc.kill.assert_called_once() + + +# --- Max-turns detection (named per spec) ---------------------------------- + + +def test_main_max_turns_detected(monkeypatch, main_argv): + """stdout containing stop_reason:max_turns changes status to MAX TURNS HIT + in notification even when exit_code==0.""" + argv, lock_file, stderr_log = main_argv + + # Determine where main() will write its stdout log + branch_dir = lock_file.parent.parent + stdout_log = branch_dir / "logs" / "dispatch_stdout.log" + stdout_log.parent.mkdir(parents=True, exist_ok=True) + + def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch): + # Write max_turns stop_reason into stdout log + Path(stdout_log_path).write_text( + '{"stop_reason":"max_turns"}', encoding="utf-8" + ) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", fake_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + mock_notify = MagicMock() + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.notify.send_notification", + mock_notify, + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + mock_notify.assert_called_once() + title = mock_notify.call_args[0][0] + assert "MAX TURNS HIT" in title + + +# --- Log rotation tests (named per spec) ----------------------------------- + + +def test_stderr_rotation(tmp_path, monkeypatch): + """stderr log > 512KB triggers rotation to .log.1.""" + stderr_log = tmp_path / "stderr.log" + stderr_log.write_text("x" * 520_000, encoding="utf-8") + + branch_dir = tmp_path / "branch" + ai_mail_dir = branch_dir / ".ai_mail.local" + ai_mail_dir.mkdir(parents=True) + logs_dir = branch_dir / "logs" + logs_dir.mkdir(parents=True) + + lock = ai_mail_dir / ".dispatch.lock" + lock.write_text("{}", encoding="utf-8") + + argv = [ + "dispatch_monitor.py", "@test", str(lock), + "@sender", str(stderr_log), "--", "claude", + ] + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=tmp_path), + ) + + with pytest.raises(SystemExit): + main() + + rotated = tmp_path / "stderr.log.1" + assert rotated.exists() + assert rotated.stat().st_size >= 520_000 + + +def test_stdout_rotation(tmp_path, monkeypatch): + """stdout log > 512KB triggers rotation to .log.1 before first attempt.""" + branch_dir = tmp_path / "branch" + ai_mail_dir = branch_dir / ".ai_mail.local" + ai_mail_dir.mkdir(parents=True) + logs_dir = branch_dir / "logs" + logs_dir.mkdir(parents=True) + + lock = ai_mail_dir / ".dispatch.lock" + lock.write_text("{}", encoding="utf-8") + stderr_log = tmp_path / "stderr.log" + stderr_log.write_text("", encoding="utf-8") + + stdout_log = logs_dir / "dispatch_stdout.log" + stdout_log.write_text("x" * 520_000, encoding="utf-8") + + argv = [ + "dispatch_monitor.py", "@test", str(lock), + "@sender", str(stderr_log), "--", "claude", + ] + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=tmp_path), + ) + + with pytest.raises(SystemExit): + main() + + rotated = logs_dir / "dispatch_stdout.log.1" + assert rotated.exists() + + +# --- Lock file cleanup tests (named per spec) ------------------------------ + + +def test_lock_cleaned_on_success(monkeypatch, main_argv): + """Lock is deleted when exit_code==0.""" + argv, lock_file, stderr_log = main_argv + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert not lock_file.exists() + + +def test_lock_cleaned_on_failure(monkeypatch, main_argv): + """Lock is deleted even when all attempts fail.""" + argv, lock_file, stderr_log = main_argv + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", + MagicMock(side_effect=[(1, False), (1, False), (1, False)])) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr(mod, "time", MagicMock( + time=time.time, strftime=time.strftime, sleep=MagicMock(), + )) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert not lock_file.exists() + + +# --- Environment variables tests (named per spec) -------------------------- + + +def test_env_vars_setup(monkeypatch, main_argv): + """Verify spawn_env contains AIPASS_SPAWNED=1, AIPASS_SESSION_TYPE=dispatched, + AIPASS_BRANCH_NAME set, CLAUDE* vars stripped, venv bin on PATH.""" + argv, lock_file, stderr_log = main_argv + + captured_env = {} + + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch): + captured_env.update(env) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + + fake_repo = Path("/fake/repo") + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=fake_repo), + ) + + # Set CLAUDE* and AIPASS_BOT_ID vars that should be stripped + monkeypatch.setenv("CLAUDE_ACCESS_TOKEN", "secret") + monkeypatch.setenv("CLAUDE_SESSION_ID", "abc123") + monkeypatch.setenv("AIPASS_BOT_ID", "bot42") + monkeypatch.setenv("AIPASS_CALLER_BRANCH", "@other") + monkeypatch.setenv("AIPASS_CALLER_CWD", "/other/cwd") + + with pytest.raises(SystemExit): + main() + + assert captured_env["AIPASS_SPAWNED"] == "1" + assert captured_env["AIPASS_SESSION_TYPE"] == "dispatched" + assert captured_env["AIPASS_BRANCH_NAME"] == "test_branch" + assert "CLAUDE_ACCESS_TOKEN" not in captured_env + assert "CLAUDE_SESSION_ID" not in captured_env + assert "AIPASS_BOT_ID" not in captured_env + assert "AIPASS_CALLER_BRANCH" not in captured_env + assert "AIPASS_CALLER_CWD" not in captured_env + assert "/fake/repo/.venv/bin" in captured_env.get("PATH", "") + + +# --- JSONL helper tests ---------------------------------------------------- + + +def test_get_jsonl_projects_dir(): + """Verifies path encoding: / replaced with -, _ replaced with -.""" + result = _get_jsonl_projects_dir("/home/user/my_project") + expected = Path.home() / ".claude" / "projects" / "-home-user-my-project" + assert result == expected + + +def test_snapshot_jsonl_sizes(tmp_path): + """Creates .jsonl files in tmp_path and verifies correct size dict.""" + f1 = tmp_path / "session1.jsonl" + f2 = tmp_path / "session2.jsonl" + f1.write_text("line1\n", encoding="utf-8") + f2.write_text("line1\nline2\n", encoding="utf-8") + + sizes = _snapshot_jsonl_sizes(tmp_path) + assert sizes["session1.jsonl"] == f1.stat().st_size + assert sizes["session2.jsonl"] == f2.stat().st_size + assert len(sizes) == 2 + + +def test_snapshot_jsonl_sizes_empty_dir(tmp_path): + """Returns empty dict for a directory with no .jsonl files.""" + sizes = _snapshot_jsonl_sizes(tmp_path) + assert sizes == {} + + +def test_snapshot_jsonl_sizes_missing_dir(tmp_path): + """Returns empty dict for a nonexistent directory.""" + sizes = _snapshot_jsonl_sizes(tmp_path / "does_not_exist") + assert sizes == {} + + +def test_check_jsonl_activity_new_file(tmp_path): + """New file appears after snapshot -> True.""" + initial = _snapshot_jsonl_sizes(tmp_path) + assert initial == {} + + # New file appears + (tmp_path / "new_session.jsonl").write_text("data\n", encoding="utf-8") + + assert _check_jsonl_activity(tmp_path, initial) is True + + +def test_check_jsonl_activity_file_grew(tmp_path): + """Existing file larger than snapshot -> True.""" + f = tmp_path / "session.jsonl" + f.write_text("line1\n", encoding="utf-8") + + initial = _snapshot_jsonl_sizes(tmp_path) + + # File grows + with open(f, "a", encoding="utf-8") as fh: + fh.write("line2\n") + + assert _check_jsonl_activity(tmp_path, initial) is True + + +def test_check_jsonl_activity_no_change(tmp_path): + """No change -> False.""" + f = tmp_path / "session.jsonl" + f.write_text("line1\n", encoding="utf-8") + + initial = _snapshot_jsonl_sizes(tmp_path) + + assert _check_jsonl_activity(tmp_path, initial) is False + + +def test_check_jsonl_activity_missing_dir(tmp_path): + """Nonexistent directory -> False.""" + assert _check_jsonl_activity(tmp_path / "nope", {}) is False diff --git a/src/aipass/ai_mail/tests/test_notify.py b/src/aipass/ai_mail/tests/test_notify.py new file mode 100644 index 00000000..84b52afd --- /dev/null +++ b/src/aipass/ai_mail/tests/test_notify.py @@ -0,0 +1,196 @@ +# =================== AIPass ==================== +# Name: test_notify.py +# Description: Tests for desktop notification handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for notify module -- dbus and notify-send notification paths.""" + +import subprocess +import pytest +from unittest.mock import MagicMock, patch, call + +import aipass.ai_mail.apps.handlers.notify as mod + + +# --- Fixtures -------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _suppress_log_operation(monkeypatch): + """Prevent json_handler.log_operation from touching real files.""" + monkeypatch.setattr(mod, "json_handler", MagicMock()) + + +@pytest.fixture(autouse=True) +def _suppress_logger(monkeypatch): + """Suppress logger output during tests.""" + monkeypatch.setattr(mod, "logger", MagicMock()) + + +# --- send_notification tests ------------------------------------------ + + +def test_send_notification_returns_true_when_dbus_succeeds(monkeypatch): + """Returns True when dbus path succeeds on first try.""" + monkeypatch.setattr(mod, "_send_via_dbus", lambda *a: True) + monkeypatch.setattr(mod, "_send_via_notify_send", lambda *a: False) + + result = mod.send_notification("Title", "Body", "spawn") + assert result is True + + +def test_send_notification_falls_back_to_notify_send(monkeypatch): + """Falls back to notify-send when dbus fails, returns True on fallback success.""" + monkeypatch.setattr(mod, "_send_via_dbus", lambda *a: False) + monkeypatch.setattr(mod, "_send_via_notify_send", lambda *a: True) + + result = mod.send_notification("Title", "Body", "spawn") + assert result is True + + +def test_send_notification_returns_false_when_both_fail(monkeypatch): + """Returns False when both dbus and notify-send fail.""" + monkeypatch.setattr(mod, "_send_via_dbus", lambda *a: False) + monkeypatch.setattr(mod, "_send_via_notify_send", lambda *a: False) + + result = mod.send_notification("Title", "Body", "spawn") + assert result is False + + +def test_send_notification_default_source(monkeypatch): + """Default source parameter is 'ai_mail'.""" + captured = {} + + def fake_dbus(title, body, source, icon): + captured["source"] = source + return True + + monkeypatch.setattr(mod, "_send_via_dbus", fake_dbus) + + mod.send_notification("Title", "Body") + assert captured["source"] == "ai_mail" + + +# --- _send_via_dbus tests -------------------------------------------- + + +def test_send_via_dbus_constructs_correct_command(monkeypatch): + """Passes correct arguments to subprocess.run.""" + captured_args = {} + + def fake_run(cmd, **kwargs): + captured_args["cmd"] = cmd + captured_args["kwargs"] = kwargs + result = MagicMock() + result.returncode = 0 + return result + + monkeypatch.setattr(mod.subprocess, "run", fake_run) + monkeypatch.setattr(mod.shutil, "which", lambda name: "/usr/bin/python3") + + mod._send_via_dbus("Test Title", "Test Body", "drone", "dialog-warning") + + cmd = captured_args["cmd"] + assert cmd[0] == "/usr/bin/python3" + assert cmd[1] == "-c" + assert cmd[2] == mod._DBUS_SCRIPT + assert cmd[3] == "drone" + assert cmd[4] == "dialog-warning" + assert cmd[5] == "Test Title" + assert cmd[6] == "Test Body" + assert captured_args["kwargs"]["timeout"] == 5 + + +def test_send_via_dbus_returns_true_on_success(monkeypatch): + """Returns True when subprocess exits with code 0.""" + mock_result = MagicMock() + mock_result.returncode = 0 + monkeypatch.setattr(mod.subprocess, "run", lambda *a, **kw: mock_result) + monkeypatch.setattr(mod.shutil, "which", lambda name: "/usr/bin/python3") + + assert mod._send_via_dbus("T", "B", "s", "i") is True + + +def test_send_via_dbus_returns_false_on_nonzero_exit(monkeypatch): + """Returns False when subprocess exits with nonzero code.""" + mock_result = MagicMock() + mock_result.returncode = 1 + monkeypatch.setattr(mod.subprocess, "run", lambda *a, **kw: mock_result) + monkeypatch.setattr(mod.shutil, "which", lambda name: "/usr/bin/python3") + + assert mod._send_via_dbus("T", "B", "s", "i") is False + + +def test_send_via_dbus_returns_false_on_subprocess_error(monkeypatch): + """Returns False on SubprocessError.""" + def raise_error(*a, **kw): + raise subprocess.SubprocessError("timeout") + + monkeypatch.setattr(mod.subprocess, "run", raise_error) + monkeypatch.setattr(mod.shutil, "which", lambda name: "/usr/bin/python3") + + assert mod._send_via_dbus("T", "B", "s", "i") is False + + +def test_send_via_dbus_returns_false_on_file_not_found(monkeypatch): + """Returns False when python binary not found.""" + def raise_error(*a, **kw): + raise FileNotFoundError("python3") + + monkeypatch.setattr(mod.subprocess, "run", raise_error) + monkeypatch.setattr(mod.shutil, "which", lambda name: None) + + assert mod._send_via_dbus("T", "B", "s", "i") is False + + +# --- _send_via_notify_send tests -------------------------------------- + + +def test_send_via_notify_send_returns_true_on_success(monkeypatch): + """Returns True when notify-send succeeds.""" + mock_result = MagicMock() + mock_result.returncode = 0 + monkeypatch.setattr(mod.subprocess, "run", lambda *a, **kw: mock_result) + + assert mod._send_via_notify_send("Title", "Body", "dialog-information") is True + + +def test_send_via_notify_send_passes_correct_args(monkeypatch): + """Passes correct arguments to subprocess.run.""" + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return MagicMock(returncode=0) + + monkeypatch.setattr(mod.subprocess, "run", fake_run) + + mod._send_via_notify_send("Hello", "World", "dialog-warning") + + assert captured["cmd"] == ["notify-send", "-i", "dialog-warning", "Hello", "World"] + assert captured["kwargs"]["capture_output"] is True + assert captured["kwargs"]["timeout"] == 5 + + +def test_send_via_notify_send_returns_false_on_file_not_found(monkeypatch): + """Returns False when notify-send is not installed.""" + def raise_error(*a, **kw): + raise FileNotFoundError("notify-send") + + monkeypatch.setattr(mod.subprocess, "run", raise_error) + + assert mod._send_via_notify_send("T", "B", "i") is False + + +def test_send_via_notify_send_returns_false_on_subprocess_error(monkeypatch): + """Returns False on SubprocessError.""" + def raise_error(*a, **kw): + raise subprocess.SubprocessError("broken pipe") + + monkeypatch.setattr(mod.subprocess, "run", raise_error) + + assert mod._send_via_notify_send("T", "B", "i") is False diff --git a/src/aipass/ai_mail/tests/test_paths.py b/src/aipass/ai_mail/tests/test_paths.py new file mode 100644 index 00000000..4e4fbd9f --- /dev/null +++ b/src/aipass/ai_mail/tests/test_paths.py @@ -0,0 +1,86 @@ +# =================== AIPass ==================== +# Name: test_paths.py +# Description: Tests for shared path utilities +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for paths module -- repo root discovery.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock + +import aipass.ai_mail.apps.handlers.paths as mod + + +# --- Fixtures -------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _suppress_log_operation(monkeypatch): + """Prevent json_handler.log_operation from touching real files.""" + monkeypatch.setattr(mod, "json_handler", MagicMock()) + + +# --- find_repo_root tests -------------------------------------------- + + +def test_find_repo_root_finds_registry(tmp_path, monkeypatch): + """Returns ancestor directory that contains AIPASS_REGISTRY.json.""" + # Build a directory tree: tmp_path/repo/src/pkg/ + repo_dir = tmp_path / "repo" + pkg_dir = repo_dir / "src" / "pkg" + pkg_dir.mkdir(parents=True) + (repo_dir / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + # Patch __file__ so the walk starts inside pkg_dir + fake_file = pkg_dir / "paths.py" + fake_file.write_text("", encoding="utf-8") + monkeypatch.setattr(mod, "__file__", str(fake_file)) + + result = mod.find_repo_root() + assert result == repo_dir + + +def test_find_repo_root_returns_cwd_when_no_registry(tmp_path, monkeypatch): + """Returns Path.cwd() when no ancestor contains AIPASS_REGISTRY.json.""" + # Use an isolated directory with no registry file anywhere above + isolated = tmp_path / "nowhere" / "deep" + isolated.mkdir(parents=True) + fake_file = isolated / "paths.py" + fake_file.write_text("", encoding="utf-8") + + monkeypatch.setattr(mod, "__file__", str(fake_file)) + monkeypatch.chdir(tmp_path) + + result = mod.find_repo_root() + assert result == Path(str(tmp_path)) + + +def test_find_repo_root_finds_registry_at_immediate_parent(tmp_path, monkeypatch): + """Returns the immediate parent when AIPASS_REGISTRY.json is one level up.""" + parent_dir = tmp_path / "project" + child_dir = parent_dir / "child" + child_dir.mkdir(parents=True) + (parent_dir / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + fake_file = child_dir / "paths.py" + fake_file.write_text("", encoding="utf-8") + monkeypatch.setattr(mod, "__file__", str(fake_file)) + + result = mod.find_repo_root() + assert result == parent_dir + + +def test_find_repo_root_finds_registry_in_same_dir(tmp_path, monkeypatch): + """Returns the directory itself when AIPASS_REGISTRY.json is in the same dir.""" + (tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + fake_file = tmp_path / "paths.py" + fake_file.write_text("", encoding="utf-8") + monkeypatch.setattr(mod, "__file__", str(fake_file)) + + result = mod.find_repo_root() + assert result == tmp_path diff --git a/src/aipass/api/tests/test_caller.py b/src/aipass/api/tests/test_caller.py new file mode 100644 index 00000000..18aa577e --- /dev/null +++ b/src/aipass/api/tests/test_caller.py @@ -0,0 +1,91 @@ +# =================== AIPass ==================== +# Name: test_caller.py +# Description: Tests for OpenRouter caller detection handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for openrouter.caller — caller detection handler. + +Tests: +- detect_caller_category for flow paths +- detect_caller_category for prax paths +- detect_caller_category for skills paths +- detect_caller_category for unknown paths +- detect_caller_category with 'skills' as substring in a part +""" + +from unittest.mock import patch, MagicMock +from pathlib import Path + +import pytest + +from aipass.api.apps.handlers.openrouter.caller import detect_caller_category + + +# ============================================= +# detect_caller_category tests +# ============================================= + + +class TestDetectCallerCategory: + """Tests for caller.detect_caller_category().""" + + def test_flow_path_returns_flow(self): + """Path containing 'flow' part should return 'flow'.""" + path = Path("/home/user/projects/aipass/src/aipass/flow/engine.py") + assert detect_caller_category(path) == "flow" + + def test_prax_path_returns_prax(self): + """Path containing 'prax' part should return 'prax'.""" + path = Path("/home/user/projects/aipass/src/aipass/prax/monitor.py") + assert detect_caller_category(path) == "prax" + + def test_skills_exact_part_returns_skills(self): + """Path with exact 'skills' directory should return 'skills'.""" + path = Path("/home/user/projects/aipass/src/aipass/skills/skills_api/tool.py") + assert detect_caller_category(path) == "skills" + + def test_skills_substring_returns_skills(self): + """Path with 'skills' as substring in a part (e.g., 'skills_api') should return 'skills'.""" + path = Path("/home/user/projects/aipass/src/aipass/modules/skills_custom/handler.py") + assert detect_caller_category(path) == "skills" + + def test_unknown_path_returns_unknown(self): + """Path without flow, prax, or skills should return 'unknown'.""" + path = Path("/home/user/projects/aipass/src/aipass/api/apps/handler.py") + assert detect_caller_category(path) == "unknown" + + def test_flow_takes_priority_over_later_prax(self): + """If 'flow' appears before 'prax' in path, should return 'flow'.""" + path = Path("/home/user/flow/prax/script.py") + assert detect_caller_category(path) == "flow" + + def test_prax_takes_priority_over_skills(self): + """If 'prax' appears before a skills part, should return 'prax'.""" + path = Path("/home/user/prax/skills_module/script.py") + assert detect_caller_category(path) == "prax" + + def test_root_path_returns_unknown(self): + """Root-level path should return 'unknown'.""" + path = Path("/somefile.py") + assert detect_caller_category(path) == "unknown" + + def test_deeply_nested_flow_path(self): + """Deeply nested path with 'flow' should still return 'flow'.""" + path = Path("/a/b/c/d/flow/e/f/g/handler.py") + assert detect_caller_category(path) == "flow" + + @patch("aipass.api.apps.handlers.openrouter.caller.logger") + def test_exception_returns_unknown(self, mock_logger): + """If an exception occurs, should return 'unknown' and log error.""" + bad_path = MagicMock(spec=Path) + bad_path.parts = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom"))) + type(bad_path).parts = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom"))) + + result = detect_caller_category(bad_path) + + assert result == "unknown" + mock_logger.error.assert_called_once() diff --git a/src/aipass/api/tests/test_config_provider.py b/src/aipass/api/tests/test_config_provider.py new file mode 100644 index 00000000..139d3b3b --- /dev/null +++ b/src/aipass/api/tests/test_config_provider.py @@ -0,0 +1,199 @@ +# =================== AIPass ==================== +# Name: test_config_provider.py +# Description: Tests for provider configuration handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for config.provider — provider configuration handler. + +Tests: +- merge_configs deep merge behavior +- merge_configs in-place mutation and return value +- merge_configs nested dict recursion +- merge_configs non-dict overwrite +- get_validation_rules known providers +- get_validation_rules unknown provider returns None +""" + +from unittest.mock import patch, MagicMock + +import pytest + +from aipass.api.apps.handlers.config import provider as config_provider + + +# ============================================= +# merge_configs tests +# ============================================= + + +class TestMergeConfigs: + """Tests for config.provider.merge_configs().""" + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_simple_merge_adds_new_key(self, mock_jh): + """New key in updates should appear in base.""" + base = {"a": 1} + updates = {"b": 2} + + result = config_provider.merge_configs(base, updates) + + assert result["a"] == 1 + assert result["b"] == 2 + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_simple_merge_overwrites_existing_key(self, mock_jh): + """Existing key should be overwritten by updates.""" + base = {"a": 1} + updates = {"a": 99} + + result = config_provider.merge_configs(base, updates) + + assert result["a"] == 99 + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_nested_dict_merges_recursively(self, mock_jh): + """Nested dicts should merge recursively, preserving untouched keys.""" + base = {"a": 1, "b": {"c": 2, "d": 3}} + updates = {"b": {"c": 99}, "e": 4} + + result = config_provider.merge_configs(base, updates) + + assert result["a"] == 1 + assert result["b"]["c"] == 99 + assert result["b"]["d"] == 3 + assert result["e"] == 4 + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_modifies_base_in_place(self, mock_jh): + """merge_configs should modify base dict in-place.""" + base = {"a": 1} + updates = {"b": 2} + + result = config_provider.merge_configs(base, updates) + + assert result is base + assert base["b"] == 2 + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_returns_same_object_as_base(self, mock_jh): + """Return value should be the same object as the input base.""" + base = {"x": "original"} + updates = {"y": "added"} + + result = config_provider.merge_configs(base, updates) + + assert result is base + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_non_dict_value_overwrites_dict(self, mock_jh): + """Non-dict update value should overwrite existing dict value.""" + base = {"a": {"nested": True}} + updates = {"a": "flat_string"} + + result = config_provider.merge_configs(base, updates) + + assert result["a"] == "flat_string" + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_dict_value_overwrites_non_dict(self, mock_jh): + """Dict update value should overwrite existing non-dict value.""" + base = {"a": "flat_string"} + updates = {"a": {"nested": True}} + + result = config_provider.merge_configs(base, updates) + + assert result["a"] == {"nested": True} + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_empty_updates_leaves_base_unchanged(self, mock_jh): + """Empty updates dict should not change base.""" + base = {"a": 1, "b": 2} + original = base.copy() + updates = {} + + config_provider.merge_configs(base, updates) + + assert base == original + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_deeply_nested_merge(self, mock_jh): + """Three levels of nesting should all merge correctly.""" + base = {"level1": {"level2": {"level3": "old", "keep": "yes"}}} + updates = {"level1": {"level2": {"level3": "new"}}} + + result = config_provider.merge_configs(base, updates) + + assert result["level1"]["level2"]["level3"] == "new" + assert result["level1"]["level2"]["keep"] == "yes" + + @patch("aipass.api.apps.handlers.config.provider.json_handler") + def test_logs_operation_on_merge(self, mock_jh): + """merge_configs should call json_handler.log_operation.""" + base = {"a": 1} + updates = {"b": 2, "c": 3} + + config_provider.merge_configs(base, updates) + + mock_jh.log_operation.assert_called_once_with( + "config_merged", {"keys_updated": 2} + ) + + +# ============================================= +# get_validation_rules tests (config.provider) +# ============================================= + + +class TestGetValidationRulesConfigProvider: + """Tests for config.provider.get_validation_rules().""" + + @patch("aipass.api.apps.handlers.config.provider.logger") + def test_openrouter_rules(self, mock_logger): + """openrouter should have prefix 'sk-or-v1-' and min_length 40.""" + rules = config_provider.get_validation_rules("openrouter") + + assert rules is not None + assert rules["prefix"] == "sk-or-v1-" + assert rules["min_length"] == 40 + + @patch("aipass.api.apps.handlers.config.provider.logger") + def test_openai_rules(self, mock_logger): + """openai should have prefix 'sk-' and min_length 40.""" + rules = config_provider.get_validation_rules("openai") + + assert rules is not None + assert rules["prefix"] == "sk-" + assert rules["min_length"] == 40 + + @patch("aipass.api.apps.handlers.config.provider.logger") + def test_unknown_provider_returns_none(self, mock_logger): + """Unknown provider should return None (no generic fallback).""" + rules = config_provider.get_validation_rules("unknown_provider") + + assert rules is None + + @patch("aipass.api.apps.handlers.config.provider.logger") + def test_unknown_provider_logs_info(self, mock_logger): + """Unknown provider should log an info message.""" + config_provider.get_validation_rules("nonexistent") + + mock_logger.info.assert_called_once() + assert "nonexistent" in mock_logger.info.call_args[0][0] + + @patch("aipass.api.apps.handlers.config.provider.logger") + def test_known_provider_does_not_log(self, mock_logger): + """Known provider should not trigger the info log.""" + config_provider.get_validation_rules("openrouter") + + mock_logger.info.assert_not_called() + + @patch("aipass.api.apps.handlers.config.provider.logger") + def test_return_type_is_dict_for_known(self, mock_logger): + """Known providers should return a dict.""" + for name in ["openrouter", "openai"]: + rules = config_provider.get_validation_rules(name) + assert isinstance(rules, dict), f"Expected dict for {name}" diff --git a/src/aipass/api/tests/test_tracking.py b/src/aipass/api/tests/test_tracking.py new file mode 100644 index 00000000..ed23745c --- /dev/null +++ b/src/aipass/api/tests/test_tracking.py @@ -0,0 +1,354 @@ +# =================== AIPass ==================== +# Name: test_tracking.py +# Description: Tests for usage tracking handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for tracking.py -- usage tracking handler. + +Tests: +- get_generation_metrics() HTTP success, non-200, invalid structure, exception +- store_usage_data() new file creation, existing file update, per-caller stats, + daily totals, newest-first ordering, exception handling +""" + +import json +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from aipass.api.apps.handlers.usage.tracking import ( + get_generation_metrics, + store_usage_data, +) + +_TRACKING_MOD = "aipass.api.apps.handlers.usage.tracking" + + +# ============================================= +# get_generation_metrics tests +# ============================================= + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_success(mock_requests: MagicMock): + """Returns metrics dict when API returns 200 with valid structure.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": { + "total_cost": 0.0042, + "tokens_prompt": 150, + "tokens_completion": 80, + "generation_time": 1200, + "latency": 1500, + "provider_name": "anthropic", + } + } + mock_requests.get.return_value = mock_response + + result = get_generation_metrics("gen-abc-123", "sk-or-test-key") + + assert result is not None + assert result["total_cost"] == 0.0042 + assert result["tokens_prompt"] == 150 + assert result["tokens_completion"] == 80 + assert result["generation_time"] == 1200 + assert result["latency"] == 1500 + assert result["provider_name"] == "anthropic" + + # Verify request was made with correct params + mock_requests.get.assert_called_once() + call_kwargs = mock_requests.get.call_args + assert call_kwargs[1]["params"] == {"id": "gen-abc-123"} + assert "Bearer sk-or-test-key" in call_kwargs[1]["headers"]["Authorization"] + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_non_200(mock_requests: MagicMock): + """Returns None when API returns non-200 status.""" + mock_response = MagicMock() + mock_response.status_code = 404 + mock_requests.get.return_value = mock_response + + result = get_generation_metrics("gen-missing", "sk-or-key") + + assert result is None + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_invalid_structure_no_data_key(mock_requests: MagicMock): + """Returns None when response JSON lacks 'data' key.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"error": "not found"} + mock_requests.get.return_value = mock_response + + result = get_generation_metrics("gen-bad", "sk-or-key") + + assert result is None + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_invalid_structure_empty_response(mock_requests: MagicMock): + """Returns None when response JSON is empty/None.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = None + mock_requests.get.return_value = mock_response + + result = get_generation_metrics("gen-empty", "sk-or-key") + + assert result is None + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_request_exception(mock_requests: MagicMock): + """Returns None when requests raises an exception.""" + import requests as real_requests + + mock_requests.get.side_effect = real_requests.exceptions.ConnectionError("refused") + mock_requests.exceptions = real_requests.exceptions + + result = get_generation_metrics("gen-fail", "sk-or-key") + + assert result is None + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_timeout(mock_requests: MagicMock): + """Returns None on request timeout.""" + import requests as real_requests + + mock_requests.get.side_effect = real_requests.exceptions.Timeout("timed out") + mock_requests.exceptions = real_requests.exceptions + + result = get_generation_metrics("gen-timeout", "sk-or-key") + + assert result is None + + +@patch(f"{_TRACKING_MOD}.requests") +def test_get_generation_metrics_defaults_missing_fields(mock_requests: MagicMock): + """Missing fields in metrics default to 0 / 'unknown'.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {}} + mock_requests.get.return_value = mock_response + + result = get_generation_metrics("gen-sparse", "sk-or-key") + + assert result is not None + assert result["total_cost"] == 0.0 + assert result["tokens_prompt"] == 0 + assert result["tokens_completion"] == 0 + assert result["generation_time"] == 0 + assert result["latency"] == 0 + assert result["provider_name"] == "unknown" + + +# ============================================= +# store_usage_data tests +# ============================================= + + +@patch(f"{_TRACKING_MOD}.API_JSON_DIR") +def test_store_usage_data_creates_new_file(mock_dir: MagicMock, tmp_path: Path): + """store_usage_data creates initial structure when file doesn't exist.""" + mock_dir.__truediv__ = lambda self, other: tmp_path / other + mock_dir.mkdir = MagicMock() + + metrics = { + "total_cost": 0.005, + "tokens_prompt": 100, + "tokens_completion": 50, + "generation_time": 800, + "latency": 1000, + "provider_name": "anthropic", + } + + result = store_usage_data("test_caller", "anthropic/claude-3.5-sonnet", "gen-001", metrics) + + assert result is True + + data_path = tmp_path / "usage_tracker_data.json" + assert data_path.exists() + + with open(data_path, "r", encoding="utf-8") as f: + wrapper = json.load(f) + + assert wrapper["module_name"] == "api_usage" + data = wrapper["data"] + + # Session totals + assert data["current_session"]["total_requests"] == 1 + assert data["current_session"]["total_cost"] == 0.005 + assert data["current_session"]["total_tokens"] == 150 + + # Per-caller stats + assert "test_caller" in data["usage_by_caller"] + caller_data = data["usage_by_caller"]["test_caller"] + assert caller_data["requests"] == 1 + assert caller_data["total_cost"] == 0.005 + assert caller_data["total_tokens"] == 150 + assert caller_data["models_used"]["anthropic/claude-3.5-sonnet"] == 1 + + # Generation tracking + assert "gen-001" in data["generation_tracking"] + entry = data["generation_tracking"]["gen-001"] + assert entry["caller"] == "test_caller" + assert entry["model"] == "anthropic/claude-3.5-sonnet" + assert entry["usage_data"] == metrics + + +@patch(f"{_TRACKING_MOD}.API_JSON_DIR") +def test_store_usage_data_updates_existing(mock_dir: MagicMock, tmp_path: Path): + """store_usage_data increments counters in existing file.""" + mock_dir.__truediv__ = lambda self, other: tmp_path / other + mock_dir.mkdir = MagicMock() + + metrics = { + "total_cost": 0.01, + "tokens_prompt": 200, + "tokens_completion": 100, + "generation_time": 500, + "latency": 700, + "provider_name": "openai", + } + + # First call creates the file + store_usage_data("caller_a", "openai/gpt-4", "gen-100", metrics) + + # Second call updates + result = store_usage_data("caller_a", "openai/gpt-4", "gen-101", metrics) + + assert result is True + + data_path = tmp_path / "usage_tracker_data.json" + with open(data_path, "r", encoding="utf-8") as f: + wrapper = json.load(f) + + data = wrapper["data"] + assert data["current_session"]["total_requests"] == 2 + assert data["current_session"]["total_cost"] == pytest.approx(0.02) + assert data["current_session"]["total_tokens"] == 600 + + caller_data = data["usage_by_caller"]["caller_a"] + assert caller_data["requests"] == 2 + assert caller_data["models_used"]["openai/gpt-4"] == 2 + + +@patch(f"{_TRACKING_MOD}.API_JSON_DIR") +def test_store_usage_data_newest_first_ordering(mock_dir: MagicMock, tmp_path: Path): + """Generation tracking stores newest entry first.""" + mock_dir.__truediv__ = lambda self, other: tmp_path / other + mock_dir.mkdir = MagicMock() + + metrics = { + "total_cost": 0.001, + "tokens_prompt": 10, + "tokens_completion": 5, + "generation_time": 100, + "latency": 200, + "provider_name": "test", + } + + store_usage_data("caller", "model/a", "gen-first", metrics) + store_usage_data("caller", "model/a", "gen-second", metrics) + + data_path = tmp_path / "usage_tracker_data.json" + with open(data_path, "r", encoding="utf-8") as f: + wrapper = json.load(f) + + tracking_keys = list(wrapper["data"]["generation_tracking"].keys()) + assert tracking_keys[0] == "gen-second" + assert tracking_keys[1] == "gen-first" + + +@patch(f"{_TRACKING_MOD}.API_JSON_DIR") +def test_store_usage_data_multiple_callers(mock_dir: MagicMock, tmp_path: Path): + """store_usage_data tracks multiple callers independently.""" + mock_dir.__truediv__ = lambda self, other: tmp_path / other + mock_dir.mkdir = MagicMock() + + metrics = { + "total_cost": 0.003, + "tokens_prompt": 50, + "tokens_completion": 25, + "generation_time": 300, + "latency": 400, + "provider_name": "test", + } + + store_usage_data("caller_x", "model/x", "gen-x1", metrics) + store_usage_data("caller_y", "model/y", "gen-y1", metrics) + + data_path = tmp_path / "usage_tracker_data.json" + with open(data_path, "r", encoding="utf-8") as f: + wrapper = json.load(f) + + by_caller = wrapper["data"]["usage_by_caller"] + assert "caller_x" in by_caller + assert "caller_y" in by_caller + assert by_caller["caller_x"]["requests"] == 1 + assert by_caller["caller_y"]["requests"] == 1 + assert by_caller["caller_x"]["models_used"]["model/x"] == 1 + assert by_caller["caller_y"]["models_used"]["model/y"] == 1 + + +@patch(f"{_TRACKING_MOD}.API_JSON_DIR") +def test_store_usage_data_daily_totals(mock_dir: MagicMock, tmp_path: Path): + """store_usage_data updates daily totals for today's date.""" + from datetime import datetime + + mock_dir.__truediv__ = lambda self, other: tmp_path / other + mock_dir.mkdir = MagicMock() + + metrics = { + "total_cost": 0.002, + "tokens_prompt": 40, + "tokens_completion": 20, + "generation_time": 200, + "latency": 300, + "provider_name": "test", + } + + store_usage_data("caller", "model/a", "gen-daily", metrics) + + data_path = tmp_path / "usage_tracker_data.json" + with open(data_path, "r", encoding="utf-8") as f: + wrapper = json.load(f) + + today = datetime.now().date().isoformat() + daily = wrapper["data"]["daily_totals"] + assert today in daily + assert daily[today]["requests"] == 1 + assert daily[today]["cost"] == 0.002 + assert daily[today]["tokens"] == 60 + + +@patch(f"{_TRACKING_MOD}.API_JSON_DIR") +def test_store_usage_data_returns_false_on_exception(mock_dir: MagicMock, tmp_path: Path): + """store_usage_data returns False when an exception occurs.""" + # Point to a path that will fail (parent is a file, not a dir) + blocker = tmp_path / "blocker_file" + blocker.write_text("not a dir", encoding="utf-8") + mock_dir.__truediv__ = lambda self, other: blocker / other + mock_dir.mkdir = MagicMock(side_effect=OSError("cannot create")) + + metrics = { + "total_cost": 0.0, + "tokens_prompt": 0, + "tokens_completion": 0, + "generation_time": 0, + "latency": 0, + "provider_name": "test", + } + + result = store_usage_data("caller", "model", "gen-err", metrics) + + assert result is False diff --git a/src/aipass/backup/tests/test_drive_sync_client.py b/src/aipass/backup/tests/test_drive_sync_client.py new file mode 100644 index 00000000..c8389708 --- /dev/null +++ b/src/aipass/backup/tests/test_drive_sync_client.py @@ -0,0 +1,596 @@ +"""Tests for drive_sync_client — GoogleDriveSync core client logic.""" + +import sys +import threading +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# =================================================================== +# Fixtures +# =================================================================== + + +@pytest.fixture(autouse=True) +def mock_deps(monkeypatch): + """Mock all external deps before importing drive_sync_client.""" + mock_logger = MagicMock() + mock_prax = MagicMock() + mock_prax.logger = mock_logger + + # Already set by conftest, but ensure it stays + monkeypatch.setitem(sys.modules, "aipass.prax", mock_prax) + + # JSON handler mocks + mock_jh = MagicMock() + mock_jh.log_operation = MagicMock(return_value=True) + mock_json_init = MagicMock() + mock_json_init.json_handler = mock_jh + monkeypatch.setitem(sys.modules, "aipass.backup.apps.handlers.json", mock_json_init) + monkeypatch.setitem(sys.modules, "aipass.backup.apps.handlers.json.json_handler", mock_jh) + + # drive_sync_json mock + mock_dsj = MagicMock() + mock_dsj.load_config = MagicMock(return_value={}) + mock_dsj.save_config = MagicMock() + mock_dsj.load_data = MagicMock(return_value={}) + mock_dsj.save_data = MagicMock() + mock_dsj.log_operation = MagicMock() + monkeypatch.setitem( + sys.modules, + "aipass.backup.apps.handlers.json.drive_sync_json", + mock_dsj, + ) + + # Google API mocks + mock_get_drive_service = MagicMock() + mock_api_call_with_retry = MagicMock() + mock_google_client = MagicMock() + mock_google_client.get_drive_service = mock_get_drive_service + mock_google_client.api_call_with_retry = mock_api_call_with_retry + + monkeypatch.setitem(sys.modules, "aipass.api", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.api.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.api.apps.modules", MagicMock()) + monkeypatch.setitem( + sys.modules, + "aipass.api.apps.modules.google_client", + mock_google_client, + ) + + # googleapiclient mock (MediaFileUpload) + mock_media_upload = MagicMock() + mock_http_mod = MagicMock() + mock_http_mod.MediaFileUpload = mock_media_upload + mock_googleapiclient = MagicMock() + mock_googleapiclient.http = mock_http_mod + monkeypatch.setitem(sys.modules, "googleapiclient", mock_googleapiclient) + monkeypatch.setitem(sys.modules, "googleapiclient.http", mock_http_mod) + + # Remove cached drive_sync_client so re-import picks up mocks + for key in list(sys.modules): + if "drive_sync_client" in key: + monkeypatch.delitem(sys.modules, key, raising=False) + # Also remove handler parent packages so import chain is clean + for key in list(sys.modules): + if key.startswith("aipass.backup.apps.handlers") and "json" not in key: + monkeypatch.delitem(sys.modules, key, raising=False) + + return { + "mock_logger": mock_logger, + "mock_jh": mock_jh, + "mock_dsj": mock_dsj, + "mock_api_call_with_retry": mock_api_call_with_retry, + "mock_media_upload": mock_media_upload, + } + + +def _make_client(mock_deps): + """Create a GoogleDriveSync with mocked internals (no real file I/O).""" + from aipass.backup.apps.handlers.operations.drive_sync_client import ( + GoogleDriveSync, + ) + + with patch.object(GoogleDriveSync, "__init__", lambda self: None): + client = GoogleDriveSync() + + # Set required attributes that __init__ normally creates + client._drive_service = None + client._thread_local = threading.local() + client.backup_folder_id = None + client.project_folder_cache = {} + client.file_tracker = {} + client.data = {} + client.last_error = None + client.tracker_was_reset = False + client._folder_cache_lock = threading.Lock() + client.config = {} + + return client + + +# =================================================================== +# Tests -- drive_service property (getter / setter) +# =================================================================== + + +class TestDriveServiceProperty: + """Validate the drive_service property getter and setter.""" + + def test_drive_service_getter_returns_main_service(self, mock_deps): + """Getter returns _drive_service when no thread-local service is set.""" + client = _make_client(mock_deps) + client._drive_service = "main_svc" + # No thread-local service set + + assert client.drive_service == "main_svc" + + def test_drive_service_getter_prefers_thread_local(self, mock_deps): + """Getter returns thread-local service when both are set.""" + client = _make_client(mock_deps) + client._drive_service = "main_svc" + client._thread_local.service = "thread_svc" + + assert client.drive_service == "thread_svc" + + def test_drive_service_getter_returns_none_when_nothing_set(self, mock_deps): + """Getter returns None when neither thread-local nor main service is set.""" + client = _make_client(mock_deps) + + assert client.drive_service is None + + def test_drive_service_setter(self, mock_deps): + """Setter updates _drive_service attribute.""" + client = _make_client(mock_deps) + assert client._drive_service is None + + client.drive_service = "new_svc" + + assert client._drive_service == "new_svc" + + +# =================================================================== +# Tests -- get_or_create_backup_folder +# =================================================================== + + +class TestGetOrCreateBackupFolder: + """Validate backup folder retrieval and creation logic.""" + + def test_get_or_create_backup_folder_cached(self, mock_deps): + """Returns cached folder ID when it is still valid (not trashed).""" + client = _make_client(mock_deps) + client.backup_folder_id = "cached_id" + client._drive_service = "svc" + + with patch.object(client, "_verify_folder_id", return_value=True) as mock_verify: + result = client.get_or_create_backup_folder() + + assert result == "cached_id" + mock_verify.assert_called_once_with("cached_id") + + def test_get_or_create_backup_folder_finds_existing(self, mock_deps): + """Finds an existing 'AIPass Backups' folder via Drive API search.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + # _api_call_with_retry returns search results, then verify passes + list_result = {"files": [{"id": "existing_folder_id", "name": "AIPass Backups"}]} + + with ( + patch.object( + client, "_api_call_with_retry", return_value=list_result + ) as mock_api, + patch.object(client, "_verify_folder_id", return_value=True), + ): + result = client.get_or_create_backup_folder() + + assert result == "existing_folder_id" + assert client.backup_folder_id == "existing_folder_id" + mock_api.assert_called_once() + + def test_get_or_create_backup_folder_creates_new(self, mock_deps): + """Creates new folder when no existing folder is found on Drive.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + # First call: list returns empty (no existing folder) + # Second call: create returns new folder ID + call_responses = [ + {"files": []}, # list result + {"id": "new_id"}, # create result + ] + + with ( + patch.object( + client, + "_api_call_with_retry", + side_effect=call_responses, + ), + patch.object(client, "_verify_folder_id", return_value=True), + ): + result = client.get_or_create_backup_folder() + + assert result == "new_id" + assert client.backup_folder_id == "new_id" + + def test_get_or_create_backup_folder_creates_new_resets_tracker(self, mock_deps): + """Creating a new folder resets the file tracker when entries exist.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + client.file_tracker = {"old_file": {"drive_id": "old_id"}} + client.project_folder_cache = {"proj": "old_folder"} + client.data = {"runtime_state": {"file_tracker": {}, "cached_folders": {}}} + + call_responses = [ + {"files": []}, + {"id": "brand_new_id"}, + ] + + with ( + patch.object( + client, + "_api_call_with_retry", + side_effect=call_responses, + ), + patch.object(client, "_verify_folder_id", return_value=True), + patch.object(client, "_save_file_tracker"), + ): + result = client.get_or_create_backup_folder() + + assert result == "brand_new_id" + assert client.tracker_was_reset is True + assert len(client.file_tracker) == 0 + assert len(client.project_folder_cache) == 0 + + def test_get_or_create_backup_folder_no_service_returns_none(self, mock_deps): + """Returns None when drive_service is not set.""" + client = _make_client(mock_deps) + # _drive_service stays None, no cached backup_folder_id + + result = client.get_or_create_backup_folder() + + assert result is None + assert "not authenticated" in (client.last_error or "") + + def test_get_or_create_backup_folder_cached_but_trashed(self, mock_deps): + """Clears cache and searches Drive when cached folder is trashed.""" + client = _make_client(mock_deps) + client.backup_folder_id = "trashed_id" + client._drive_service = MagicMock() + + list_result = {"files": [{"id": "fresh_id", "name": "AIPass Backups"}]} + + with ( + patch.object( + client, + "_verify_folder_id", + side_effect=[False, True], # First call: trashed; second: verify new + ), + patch.object( + client, "_api_call_with_retry", return_value=list_result + ), + ): + result = client.get_or_create_backup_folder() + + assert result == "fresh_id" + assert client.backup_folder_id == "fresh_id" + + +# =================================================================== +# Tests -- get_or_create_nested_folder +# =================================================================== + + +class TestGetOrCreateNestedFolder: + """Validate nested folder creation logic.""" + + def test_get_or_create_nested_folder_empty_path(self, mock_deps): + """Returns parent_folder_id when folder_path is '.'.""" + client = _make_client(mock_deps) + + result = client.get_or_create_nested_folder("parent_123", ".") + + assert result == "parent_123" + + def test_get_or_create_nested_folder_none_path(self, mock_deps): + """Returns parent_folder_id when folder_path is empty string.""" + client = _make_client(mock_deps) + + result = client.get_or_create_nested_folder("parent_456", "") + + assert result == "parent_456" + + def test_get_or_create_nested_folder_creates_segments(self, mock_deps): + """Creates each path segment that does not yet exist on Drive.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + # For path "a/b": two list calls (empty = not found), two create calls + call_responses = [ + {"files": []}, # search for "a" -> not found + {"id": "folder_a"}, # create "a" + {"files": []}, # search for "b" -> not found + {"id": "folder_b"}, # create "b" + ] + + with ( + patch.object( + client, + "_api_call_with_retry", + side_effect=call_responses, + ), + patch.object(client, "_verify_folder_id", return_value=False), + ): + result = client.get_or_create_nested_folder("root_id", "a/b") + + assert result == "folder_b" + # Both segments should be cached + assert "root_id:a" in client.project_folder_cache + assert "folder_a:b" in client.project_folder_cache + + def test_get_or_create_nested_folder_finds_existing_segments(self, mock_deps): + """Uses existing folders found on Drive instead of creating new ones.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + call_responses = [ + {"files": [{"id": "existing_a"}]}, # "a" exists + {"files": [{"id": "existing_b"}]}, # "b" exists + ] + + with ( + patch.object( + client, + "_api_call_with_retry", + side_effect=call_responses, + ), + patch.object(client, "_verify_folder_id", return_value=False), + ): + result = client.get_or_create_nested_folder("root_id", "a/b") + + assert result == "existing_b" + + def test_get_or_create_nested_folder_uses_cache(self, mock_deps): + """Returns cached folder ID for full path without making API calls.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + client.project_folder_cache["root_id:a/b"] = "cached_nested_id" + + with patch.object(client, "_verify_folder_id", return_value=True): + result = client.get_or_create_nested_folder("root_id", "a/b") + + assert result == "cached_nested_id" + + def test_get_or_create_nested_folder_api_error_returns_parent(self, mock_deps): + """Falls back to parent_folder_id on API error.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + with ( + patch.object( + client, + "_api_call_with_retry", + side_effect=RuntimeError("API down"), + ), + patch.object(client, "_verify_folder_id", return_value=False), + ): + result = client.get_or_create_nested_folder("fallback_id", "x/y") + + assert result == "fallback_id" + + +# =================================================================== +# Tests -- upload_backup_file +# =================================================================== + + +class TestUploadBackupFile: + """Validate backup file upload logic.""" + + def test_upload_backup_file_no_service_returns_false(self, mock_deps): + """Returns False immediately when drive_service is None.""" + client = _make_client(mock_deps) + # _drive_service stays None + + result = client.upload_backup_file( + local_file=Path("/fake/backup.tar.gz"), + project_name="TestProject", + ) + + assert result is False + + def test_upload_backup_file_success_create_new(self, mock_deps, tmp_path): + """Uploads a new file to Drive and returns True.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + # Create a real temp file so stat() works + test_file = tmp_path / "test_backup.json" + test_file.write_text('{"data": true}', encoding="utf-8") + + mock_project_folder = "proj_folder_id" + mock_target_folder = "target_folder_id" + created_file = {"id": "new_drive_file_id"} + + with ( + patch.object( + client, + "get_or_create_project_folder", + return_value=mock_project_folder, + ), + patch.object( + client, + "get_or_create_nested_folder", + return_value=mock_target_folder, + ), + patch.object( + client, + "_find_existing_file", + return_value=None, + ), + patch.object( + client, + "_api_call_with_retry", + return_value=created_file, + ), + ): + result = client.upload_backup_file( + local_file=test_file, + project_name="TestProject", + note="test upload", + ) + + assert result is True + + def test_upload_backup_file_success_update_existing(self, mock_deps, tmp_path): + """Updates an existing file on Drive using its tracked drive_id.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + test_file = tmp_path / "existing_backup.json" + test_file.write_text('{"version": 2}', encoding="utf-8") + + # Pre-populate file tracker with an existing drive_id + client.file_tracker = { + "existing_backup.json": {"drive_id": "tracked_drive_id"} + } + + updated_file = {"id": "tracked_drive_id"} + + with ( + patch.object( + client, + "get_or_create_project_folder", + return_value="proj_folder", + ), + patch.object( + client, + "get_or_create_nested_folder", + return_value="target_folder", + ), + patch.object( + client, + "_api_call_with_retry", + return_value=updated_file, + ), + ): + result = client.upload_backup_file( + local_file=test_file, + project_name="TestProject", + ) + + assert result is True + + def test_upload_backup_file_no_project_folder_returns_false( + self, mock_deps, tmp_path + ): + """Returns False when project folder creation fails.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + test_file = tmp_path / "backup.json" + test_file.write_text("{}", encoding="utf-8") + + with patch.object( + client, "get_or_create_project_folder", return_value=None + ): + result = client.upload_backup_file( + local_file=test_file, + project_name="TestProject", + ) + + assert result is False + + def test_upload_backup_file_updates_statistics(self, mock_deps, tmp_path): + """Successful upload increments upload statistics in data dict.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + client.data = {} + + test_file = tmp_path / "stats_test.json" + test_file.write_text('{"key": "val"}', encoding="utf-8") + file_size = test_file.stat().st_size + + with ( + patch.object( + client, + "get_or_create_project_folder", + return_value="pf_id", + ), + patch.object( + client, + "get_or_create_nested_folder", + return_value="tf_id", + ), + patch.object( + client, + "_find_existing_file", + return_value=None, + ), + patch.object( + client, + "_api_call_with_retry", + return_value={"id": "created_id"}, + ), + ): + client.upload_backup_file( + local_file=test_file, + project_name="TestProject", + ) + + stats = client.data["statistics"] + assert stats["total_uploads"] == 1 + assert stats["successful_uploads"] == 1 + assert stats["failed_uploads"] == 0 + assert stats["total_bytes_uploaded"] == file_size + + def test_upload_backup_file_with_nested_path(self, mock_deps, tmp_path): + """Computes correct nested folder path from backup_root.""" + client = _make_client(mock_deps) + client._drive_service = MagicMock() + + # Create nested file structure + sub_dir = tmp_path / "sub" / "deep" + sub_dir.mkdir(parents=True) + test_file = sub_dir / "nested.json" + test_file.write_text("{}", encoding="utf-8") + + nested_folder_calls = [] + + def capture_nested(parent_id, folder_path): + nested_folder_calls.append(folder_path) + return "nested_target_id" + + with ( + patch.object( + client, + "get_or_create_project_folder", + return_value="pf_id", + ), + patch.object( + client, + "get_or_create_nested_folder", + side_effect=capture_nested, + ), + patch.object( + client, + "_find_existing_file", + return_value=None, + ), + patch.object( + client, + "_api_call_with_retry", + return_value={"id": "file_id"}, + ), + ): + client.upload_backup_file( + local_file=test_file, + project_name="TestProject", + backup_root=tmp_path, + ) + + assert len(nested_folder_calls) == 1 + assert nested_folder_calls[0] == "sub/deep" diff --git a/src/aipass/backup/tests/test_system_utils.py b/src/aipass/backup/tests/test_system_utils.py new file mode 100644 index 00000000..d2dbdd1c --- /dev/null +++ b/src/aipass/backup/tests/test_system_utils.py @@ -0,0 +1,176 @@ +"""Tests for system_utils — filesystem operations (ensure_backup_directory, remove_empty_dirs).""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def _mock_prax(monkeypatch): + """Inject mock prax logger before any system_utils import.""" + mock_logger = MagicMock() + mock_prax_mod = MagicMock() + mock_prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", mock_prax_mod) + return mock_logger + + +@pytest.fixture(autouse=True) +def _mock_json_handler(monkeypatch): + """Inject mock json_handler before any system_utils import.""" + mock_jh = MagicMock() + mock_jh.log_operation = MagicMock(return_value=True) + mock_json_init = MagicMock() + mock_json_init.json_handler = mock_jh + monkeypatch.setitem(sys.modules, "aipass.backup.apps.handlers.json", mock_json_init) + monkeypatch.setitem(sys.modules, "aipass.backup.apps.handlers.json.json_handler", mock_jh) + return mock_jh + + +@pytest.fixture(autouse=True) +def _clear_system_utils_module(monkeypatch): + """Force re-import of system_utils each test to pick up fresh mocks.""" + keys_to_clear = [ + key for key in sys.modules + if key.startswith("aipass.backup.apps.handlers.utils") + ] + for key in keys_to_clear: + monkeypatch.delitem(sys.modules, key, raising=False) + + +# ─── ensure_backup_directory ──────────────────────────── + + +class TestEnsureBackupDirectory: + """Tests for ensure_backup_directory function.""" + + def test_ensure_backup_directory_creates_dest(self, tmp_path): + """is_dynamic=False creates only backup_dest, not backup_path.""" + from aipass.backup.apps.handlers.utils.system_utils import ensure_backup_directory + + backup_dest = tmp_path / "dest" + backup_path = tmp_path / "dest" / "subdir" + + success, error = ensure_backup_directory(backup_dest, backup_path, is_dynamic=False) + + assert success is True + assert error is None + assert backup_dest.exists() + # backup_path should NOT be created when is_dynamic=False + assert not backup_path.exists() + + def test_ensure_backup_directory_dynamic_creates_both(self, tmp_path): + """is_dynamic=True creates both backup_dest and backup_path.""" + from aipass.backup.apps.handlers.utils.system_utils import ensure_backup_directory + + backup_dest = tmp_path / "dest" + backup_path = tmp_path / "dest" / "subdir" + + success, error = ensure_backup_directory(backup_dest, backup_path, is_dynamic=True) + + assert success is True + assert error is None + assert backup_dest.exists() + assert backup_path.exists() + + def test_ensure_backup_directory_permission_error(self, tmp_path, monkeypatch): + """PermissionError returns (False, error_message).""" + from aipass.backup.apps.handlers.utils.system_utils import ensure_backup_directory + + backup_dest = tmp_path / "locked" + backup_path = tmp_path / "locked" / "sub" + + # Patch _mkdir_writable to raise PermissionError + monkeypatch.setattr( + "aipass.backup.apps.handlers.utils.system_utils._mkdir_writable", + MagicMock(side_effect=PermissionError("no access")), + ) + + success, error_msg = ensure_backup_directory(backup_dest, backup_path, is_dynamic=False) + + assert success is False + assert isinstance(error_msg, str) + assert "Permission denied" in error_msg + assert "no access" in error_msg + + def test_ensure_backup_directory_os_error(self, tmp_path, monkeypatch): + """OSError returns (False, error_message) with OS error details.""" + from aipass.backup.apps.handlers.utils.system_utils import ensure_backup_directory + + backup_dest = tmp_path / "broken" + backup_path = tmp_path / "broken" / "sub" + + monkeypatch.setattr( + "aipass.backup.apps.handlers.utils.system_utils._mkdir_writable", + MagicMock(side_effect=OSError("disk full")), + ) + + success, error_msg = ensure_backup_directory(backup_dest, backup_path, is_dynamic=False) + + assert success is False + assert isinstance(error_msg, str) + assert "OS error" in error_msg + assert "disk full" in error_msg + + +# ─── remove_empty_dirs ────────────────────────────────── + + +class TestRemoveEmptyDirs: + """Tests for remove_empty_dirs function.""" + + def test_remove_empty_dirs_removes_empty(self, tmp_path): + """Nested empty directories are removed bottom-up.""" + from aipass.backup.apps.handlers.utils.system_utils import remove_empty_dirs + + nested = tmp_path / "a" / "b" / "c" + nested.mkdir(parents=True) + + remove_empty_dirs(tmp_path) + + assert not (tmp_path / "a").exists() + + def test_remove_empty_dirs_keeps_nonempty(self, tmp_path): + """Directories containing files are preserved.""" + from aipass.backup.apps.handlers.utils.system_utils import remove_empty_dirs + + subdir = tmp_path / "keep_me" + subdir.mkdir() + (subdir / "data.txt").write_text("important", encoding="utf-8") + + empty_dir = tmp_path / "delete_me" + empty_dir.mkdir() + + remove_empty_dirs(tmp_path) + + assert subdir.exists() + assert (subdir / "data.txt").exists() + assert not empty_dir.exists() + + def test_remove_empty_dirs_handles_error(self, tmp_path, monkeypatch, _mock_prax): + """Exception during iterdir is caught and logged, no crash.""" + from aipass.backup.apps.handlers.utils.system_utils import remove_empty_dirs + + # Create a real directory so iterdir can be called + subdir = tmp_path / "problem" + subdir.mkdir() + + # Monkeypatch iterdir on the top-level path to raise + original_iterdir = Path.iterdir + + def broken_iterdir(self): + if self == tmp_path: + raise RuntimeError("simulated I/O failure") + return original_iterdir(self) + + monkeypatch.setattr(Path, "iterdir", broken_iterdir) + + # Should not raise + remove_empty_dirs(tmp_path) + + # Logger warning was called with the error + _mock_prax.warning.assert_called_once() + call_args = _mock_prax.warning.call_args[0][0] + assert "simulated I/O failure" in call_args diff --git a/src/aipass/cli/tests/test_init_project.py b/src/aipass/cli/tests/test_init_project.py new file mode 100644 index 00000000..79dde362 --- /dev/null +++ b/src/aipass/cli/tests/test_init_project.py @@ -0,0 +1,183 @@ +"""Tests for the CLI init_project module — aipass command routing and init orchestration.""" + +from io import StringIO +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +from rich.console import Console + +from aipass.cli.apps.modules import init_project +from aipass.cli.apps.modules.init_project import handle_command, _handle_init + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _make_capture_console(): + """Return (console, get_output) for capturing Rich output.""" + buf = StringIO() + cons = Console(file=buf, no_color=True, width=120, highlight=False) + return cons, lambda: buf.getvalue() + + +# ============================================================================= +# handle_command routing tests +# ============================================================================= + +class TestHandleCommandRouting: + """Verify handle_command dispatches correctly and returns the right boolean.""" + + def test_non_aipass_command_returns_false(self): + """Commands other than 'aipass' should return False.""" + assert handle_command("other", []) is False + assert handle_command("display", []) is False + assert handle_command("", []) is False + + def test_aipass_no_args_calls_introspection(self): + """'aipass' with no args shows introspection.""" + with patch.object(init_project, "print_introspection") as mock: + result = handle_command("aipass", []) + assert result is True + mock.assert_called_once() + + def test_aipass_help_flag(self): + """'aipass --help' shows full help.""" + with patch.object(init_project, "print_help") as mock: + result = handle_command("aipass", ["--help"]) + assert result is True + mock.assert_called_once() + + def test_aipass_dash_h_flag(self): + """'aipass -h' shows full help.""" + with patch.object(init_project, "print_help") as mock: + result = handle_command("aipass", ["-h"]) + assert result is True + mock.assert_called_once() + + def test_aipass_help_word(self): + """'aipass help' shows full help.""" + with patch.object(init_project, "print_help") as mock: + result = handle_command("aipass", ["help"]) + assert result is True + mock.assert_called_once() + + def test_aipass_init_routes_to_handle_init(self): + """'aipass init' routes to _handle_init.""" + with patch.object(init_project, "_handle_init", return_value=True) as mock: + result = handle_command("aipass", ["init"]) + assert result is True + mock.assert_called_once_with([]) + + def test_aipass_init_with_args_passes_through(self): + """'aipass init /path Name' passes args to _handle_init.""" + with patch.object(init_project, "_handle_init", return_value=True) as mock: + result = handle_command("aipass", ["init", "/path", "MyProj"]) + assert result is True + mock.assert_called_once_with(["/path", "MyProj"]) + + def test_unknown_subcommand_shows_error(self): + """Unknown aipass subcommand shows error and returns True.""" + cons, get_output = _make_capture_console() + err_cons, get_err = _make_capture_console() + with patch.object(init_project, "console", cons), \ + patch.object(init_project, "error") as mock_error: + result = handle_command("aipass", ["bogus"]) + assert result is True + mock_error.assert_called_once() + args = mock_error.call_args + assert "bogus" in args[0][0] + + +# ============================================================================= +# _handle_init tests +# ============================================================================= + +class TestHandleInit: + """Tests for the init subcommand orchestration.""" + + def test_init_help_flag(self): + """'init --help' shows init help and returns True.""" + with patch.object(init_project, "_print_init_help") as mock: + result = _handle_init(["--help"]) + assert result is True + mock.assert_called_once() + + def test_init_success_displays_output(self, tmp_path): + """Successful init displays results and logs the operation.""" + target = tmp_path / "my_project" + cons, get_output = _make_capture_console() + err_cons, get_err = _make_capture_console() + + from aipass.cli.apps.modules import display + with patch.object(init_project, "console", cons), \ + patch.object(display, "CONSOLE", cons), \ + patch.object(display, "err_console", err_cons), \ + patch.object(display, "_TRIGGER", None), \ + patch.object(display, "_TRIGGER_LOADED", True), \ + patch.object(init_project, "json_handler") as mock_json, \ + patch.object(init_project, "logger"): + result = _handle_init([str(target)]) + + assert result is True + output = get_output() + assert "Project Initialized" in output + mock_json.log_operation.assert_called_once() + call_args = mock_json.log_operation.call_args + assert call_args[0][0] == "aipass_init" + + def test_init_value_error_exits(self, tmp_path): + """ValueError from init_project causes error display and sys.exit(1).""" + with patch.object(init_project, "init_project", side_effect=ValueError("bad name")), \ + patch.object(init_project, "error") as mock_error, \ + patch.object(init_project, "logger"), \ + pytest.raises(SystemExit) as exc_info: + _handle_init([str(tmp_path)]) + assert exc_info.value.code == 1 + mock_error.assert_called_once() + assert "bad name" in mock_error.call_args[0][0] + + def test_init_file_exists_error_exits(self, tmp_path): + """FileExistsError from init_project causes error display and sys.exit(1).""" + with patch.object(init_project, "init_project", side_effect=FileExistsError("already exists")), \ + patch.object(init_project, "error") as mock_error, \ + patch.object(init_project, "logger"), \ + pytest.raises(SystemExit) as exc_info: + _handle_init([str(tmp_path)]) + assert exc_info.value.code == 1 + mock_error.assert_called_once() + + def test_init_os_error_exits(self, tmp_path): + """OSError from init_project causes error display and sys.exit(1).""" + with patch.object(init_project, "init_project", side_effect=OSError("disk full")), \ + patch.object(init_project, "error") as mock_error, \ + patch.object(init_project, "logger"), \ + pytest.raises(SystemExit) as exc_info: + _handle_init([str(tmp_path)]) + assert exc_info.value.code == 1 + mock_error.assert_called_once() + + def test_init_uses_caller_cwd_env(self, tmp_path): + """When no target arg, uses AIPASS_CALLER_CWD env var.""" + import os + target = tmp_path / "env_project" + target.mkdir() + + cons, get_output = _make_capture_console() + err_cons, get_err = _make_capture_console() + + from aipass.cli.apps.modules import display + with patch.dict(os.environ, {"AIPASS_CALLER_CWD": str(target)}), \ + patch.object(init_project, "console", cons), \ + patch.object(display, "CONSOLE", cons), \ + patch.object(display, "err_console", err_cons), \ + patch.object(display, "_TRIGGER", None), \ + patch.object(display, "_TRIGGER_LOADED", True), \ + patch.object(init_project, "json_handler") as mock_json, \ + patch.object(init_project, "logger"): + result = _handle_init([]) + + assert result is True + output = get_output() + assert "Project Initialized" in output diff --git a/src/aipass/daemon/tests/test_actions_module.py b/src/aipass/daemon/tests/test_actions_module.py new file mode 100644 index 00000000..ce5747db --- /dev/null +++ b/src/aipass/daemon/tests/test_actions_module.py @@ -0,0 +1,456 @@ +# =================== AIPass ==================== +# Name: test_actions_module.py +# Description: Tests for the actions CLI module +# Version: 1.0.0 +# Created: 2026-04-02 +# Modified: 2026-04-02 +# ============================================= + +"""Tests for the actions CLI module (apps/modules/actions.py).""" + +from datetime import datetime, timedelta +from unittest.mock import patch + +MODULE = "aipass.daemon.apps.modules.actions" + + +# ============================================= +# FIXTURES +# ============================================= + +def _make_action( + action_id: str = "0001", + name: str = "test_action", + enabled: bool = True, + schedule_type: str = "daily", + time: str = "08:00", + action_type: str = "schedule", + target_branch: str = "@seedgo", + interval_minutes: int | None = None, + due_date: str | None = None, + prompt: str = "Run tests", +) -> dict: + """Build a sample action dict for tests.""" + action: dict = { + "id": action_id, + "name": name, + "enabled": enabled, + "schedule_type": schedule_type, + "time": time, + "type": action_type, + "target_branch": target_branch, + "prompt": prompt, + "created": "2026-03-01T00:00:00", + "last_run": None, + } + if interval_minutes is not None: + action["interval_minutes"] = interval_minutes + if due_date is not None: + action["due_date"] = due_date + return action + + +# ============================================= +# handle_command — routing +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +class TestHandleCommand: + """Tests for handle_command routing.""" + + def test_wrong_command_returns_false(self, _err, _con, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("not_actions", []) is False + + def test_no_args_shows_introspection(self, _err, mock_console, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + result = handle_command("actions", []) + assert result is True + # introspection prints "actions Module" + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("actions Module" in c for c in calls) + + def test_help_flag(self, _err, mock_console, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("actions", ["--help"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("USAGE" in c for c in calls) + + def test_help_word(self, _err, mock_console, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("actions", ["help"]) is True + + @patch(f"{MODULE}.list_actions", return_value=[]) + @patch(f"{MODULE}.next_due_str", return_value="--") + def test_list_subcommand(self, _nds, _la, _err, mock_console, mock_jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("actions", ["list"]) is True + mock_jh.log_operation.assert_called_once() + + @patch(f"{MODULE}.migrate_plugins", return_value=2) + @patch(f"{MODULE}.list_actions", return_value=[]) + @patch(f"{MODULE}.next_due_str", return_value="--") + def test_migrate_subcommand(self, _nds, _la, mock_migrate, _err, _con, mock_jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("actions", ["migrate"]) is True + mock_migrate.assert_called_once() + + @patch(f"{MODULE}.get_action") + @patch(f"{MODULE}.delete_action") + def test_delete_with_valid_id(self, mock_del, mock_get, _err, _con, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + mock_get.return_value = _make_action() + assert handle_command("actions", ["delete", "0001"]) is True + mock_del.assert_called_once_with("0001") + + def test_delete_missing_id(self, mock_err, _con, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("actions", ["delete"]) is True + mock_err.assert_called() + + @patch(f"{MODULE}.create_action") + @patch(f"{MODULE}._parse_date", return_value="2026-04-09") + def test_set_reminder_valid(self, _pd, mock_create, _err, _con, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + mock_create.return_value = _make_action(action_id="0099") + assert handle_command("actions", ["set", "reminder", "7d", "Check PR"]) is True + mock_create.assert_called_once() + + @patch(f"{MODULE}.create_action") + @patch(f"{MODULE}._parse_date", return_value="2026-04-09") + def test_set_schedule_valid(self, _pd, mock_create, _err, _con, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + mock_create.return_value = _make_action(action_id="0088") + assert handle_command( + "actions", ["set", "schedule", "@seedgo", "Run audit", "daily", "04:00"] + ) is True + mock_create.assert_called_once() + + @patch(f"{MODULE}.get_action") + @patch(f"{MODULE}.next_due_str", return_value="--") + def test_action_id_routes(self, _nds, mock_get, _err, _con, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + mock_get.return_value = _make_action(action_id="0003") + assert handle_command("actions", ["0003", "info"]) is True + mock_get.assert_called_with("0003") + + def test_unknown_subcommand(self, mock_err, mock_console, _jh): + from aipass.daemon.apps.modules.actions import handle_command + + assert handle_command("actions", ["foobar"]) is True + mock_err.assert_called() + + +# ============================================= +# _handle_toggle +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +class TestHandleToggle: + + @patch(f"{MODULE}.toggle_action") + @patch(f"{MODULE}.get_action") + def test_enable_success(self, mock_get, mock_toggle, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_toggle + + mock_get.return_value = _make_action() + assert _handle_toggle("0001", True) is True + mock_toggle.assert_called_once_with("0001", True) + + @patch(f"{MODULE}.toggle_action") + @patch(f"{MODULE}.get_action") + def test_disable_success(self, mock_get, mock_toggle, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_toggle + + mock_get.return_value = _make_action() + assert _handle_toggle("0001", False) is True + mock_toggle.assert_called_once_with("0001", False) + + @patch(f"{MODULE}.get_action", return_value=None) + def test_not_found(self, _get, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_toggle + + assert _handle_toggle("9999", True) is True + mock_err.assert_called() + + +# ============================================= +# _handle_info +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +class TestHandleInfo: + + @patch(f"{MODULE}.next_due_str", return_value="--") + @patch(f"{MODULE}.get_action") + def test_info_success(self, mock_get, _nds, _err, mock_console): + from aipass.daemon.apps.modules.actions import _handle_info + + mock_get.return_value = _make_action() + assert _handle_info("0001") is True + # Should print detail header containing the action name + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("test_action" in c for c in calls) + + @patch(f"{MODULE}.get_action", return_value=None) + def test_info_not_found(self, _get, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_info + + assert _handle_info("9999") is True + mock_err.assert_called() + + +# ============================================= +# _handle_set_reminder +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +class TestHandleSetReminder: + + def test_missing_args(self, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_reminder + + assert _handle_set_reminder(["7d"]) is True + mock_err.assert_called() + + @patch(f"{MODULE}.create_action") + @patch(f"{MODULE}._parse_date", return_value="2026-04-09") + def test_with_to_flag(self, _pd, mock_create, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_reminder + + mock_create.return_value = _make_action(action_id="0050") + assert _handle_set_reminder(["7d", "Follow up", "--to", "@flow"]) is True + call_kwargs = mock_create.call_args[1] + assert call_kwargs["target_branch"] == "@flow" + + @patch(f"{MODULE}._parse_date", return_value="") + def test_invalid_date(self, _pd, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_reminder + + assert _handle_set_reminder(["xyz", "Some msg"]) is True + mock_err.assert_called() + + +# ============================================= +# _handle_set_schedule +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestHandleSetSchedule: + + def test_invalid_type(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_schedule + + assert _handle_set_schedule(["@branch", "prompt", "weekly"]) is True + mock_err.assert_called() + + def test_missing_time_arg(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_schedule + + assert _handle_set_schedule(["@branch", "prompt", "daily"]) is True + mock_err.assert_called() + + def test_interval_non_numeric(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_schedule + + assert _handle_set_schedule(["@b", "prompt", "interval", "abc"]) is True + mock_err.assert_called() + + @patch(f"{MODULE}.create_action") + def test_daily_success(self, mock_create, _log, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_schedule + + mock_create.return_value = _make_action(action_id="0070") + assert _handle_set_schedule(["@seedgo", "Run audit", "daily", "04:00"]) is True + kw = mock_create.call_args[1] + assert kw["schedule_type"] == "daily" + assert kw["time"] == "04:00" + + @patch(f"{MODULE}.create_action") + def test_hourly_success(self, mock_create, _log, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_schedule + + mock_create.return_value = _make_action(action_id="0071") + assert _handle_set_schedule(["@flow", "Check plans", "hourly", "30"]) is True + kw = mock_create.call_args[1] + assert kw["schedule_type"] == "hourly" + + @patch(f"{MODULE}.create_action") + def test_interval_success(self, mock_create, _log, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_set_schedule + + mock_create.return_value = _make_action(action_id="0072") + assert _handle_set_schedule(["@vera", "Heartbeat", "interval", "240"]) is True + kw = mock_create.call_args[1] + assert kw["interval_minutes"] == 240 + + +# ============================================= +# _handle_delete +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +class TestHandleDelete: + + def test_no_args(self, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_delete + + assert _handle_delete([]) is True + mock_err.assert_called() + + @patch(f"{MODULE}.get_action", return_value=None) + def test_not_found(self, _get, mock_err, _con): + from aipass.daemon.apps.modules.actions import _handle_delete + + assert _handle_delete(["9999"]) is True + mock_err.assert_called() + + @patch(f"{MODULE}.delete_action") + @patch(f"{MODULE}.get_action") + def test_success(self, mock_get, mock_del, _err, _con): + from aipass.daemon.apps.modules.actions import _handle_delete + + mock_get.return_value = _make_action(action_id="0005") + assert _handle_delete(["0005"]) is True + mock_del.assert_called_once_with("0005") + + +# ============================================= +# _parse_date +# ============================================= + +@patch(f"{MODULE}.logger") +class TestParseDate: + + def test_relative_days(self, _log): + from aipass.daemon.apps.modules.actions import _parse_date + + result = _parse_date("7d") + expected = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d") + assert result == expected + + def test_relative_weeks(self, _log): + from aipass.daemon.apps.modules.actions import _parse_date + + result = _parse_date("2w") + expected = (datetime.now() + timedelta(weeks=2)).strftime("%Y-%m-%d") + assert result == expected + + def test_iso_format(self, _log): + from aipass.daemon.apps.modules.actions import _parse_date + + assert _parse_date("2026-04-15") == "2026-04-15" + + def test_invalid_format(self, _log): + from aipass.daemon.apps.modules.actions import _parse_date + + assert _parse_date("not-a-date") == "" + + def test_invalid_relative_day(self, _log): + from aipass.daemon.apps.modules.actions import _parse_date + + assert _parse_date("xd") == "" + + def test_invalid_relative_week(self, _log): + from aipass.daemon.apps.modules.actions import _parse_date + + assert _parse_date("xw") == "" + + +# ============================================= +# _format_schedule +# ============================================= + +class TestFormatSchedule: + + def test_daily(self): + from aipass.daemon.apps.modules.actions import _format_schedule + + assert _format_schedule({"schedule_type": "daily", "time": "08:00"}) == "daily @ 08:00" + + def test_hourly(self): + from aipass.daemon.apps.modules.actions import _format_schedule + + assert _format_schedule({"schedule_type": "hourly", "time": "30"}) == "hourly @ :30" + + def test_interval_minutes(self): + from aipass.daemon.apps.modules.actions import _format_schedule + + assert _format_schedule({"schedule_type": "interval", "interval_minutes": 45}) == "every 45m" + + def test_interval_hours(self): + from aipass.daemon.apps.modules.actions import _format_schedule + + assert _format_schedule({"schedule_type": "interval", "interval_minutes": 120}) == "every 2h" + + def test_once(self): + from aipass.daemon.apps.modules.actions import _format_schedule + + assert _format_schedule({"schedule_type": "once", "due_date": "2026-04-10"}) == "once: 2026-04-10" + + def test_unknown_type(self): + from aipass.daemon.apps.modules.actions import _format_schedule + + assert _format_schedule({"schedule_type": "custom"}) == "custom" + + +# ============================================= +# _route_set_subcommand / _route_action_id +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +class TestRouting: + + def test_route_set_too_few_args(self, mock_err, _con): + from aipass.daemon.apps.modules.actions import _route_set_subcommand + + assert _route_set_subcommand(["set"]) is True + mock_err.assert_called() + + def test_route_set_unknown_type(self, mock_err, _con): + from aipass.daemon.apps.modules.actions import _route_set_subcommand + + assert _route_set_subcommand(["set", "bogus"]) is True + mock_err.assert_called() + + @patch(f"{MODULE}.get_action", return_value=None) + def test_route_action_id_no_sub_defaults_to_info(self, mock_get, mock_err, _con): + from aipass.daemon.apps.modules.actions import _route_action_id + + assert _route_action_id("0001", ["0001"]) is True + mock_get.assert_called_with("0001") + + @patch(f"{MODULE}.get_action") + @patch(f"{MODULE}.toggle_action") + def test_route_action_id_on(self, mock_toggle, mock_get, _err, _con): + from aipass.daemon.apps.modules.actions import _route_action_id + + mock_get.return_value = _make_action() + assert _route_action_id("0001", ["0001", "on"]) is True + mock_toggle.assert_called_once_with("0001", True) + + def test_route_action_id_unknown_sub(self, mock_err, _con): + from aipass.daemon.apps.modules.actions import _route_action_id + + assert _route_action_id("0001", ["0001", "banana"]) is True + mock_err.assert_called() diff --git a/src/aipass/daemon/tests/test_activity_report.py b/src/aipass/daemon/tests/test_activity_report.py new file mode 100644 index 00000000..d5b0a11c --- /dev/null +++ b/src/aipass/daemon/tests/test_activity_report.py @@ -0,0 +1,243 @@ +# =================== AIPass ==================== +# Name: test_activity_report.py +# Description: Tests for the activity_report CLI module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for the activity_report CLI module (apps/modules/activity_report.py).""" + +from unittest.mock import patch, MagicMock + +MODULE = "aipass.daemon.apps.modules.activity_report" + + +# ============================================= +# handle_command -- routing basics +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.error") +@patch(f"{MODULE}.logger") +class TestHandleCommandRouting: + """Tests for handle_command routing and unknown commands.""" + + def test_unknown_command_returns_false(self, _log, _err, _con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + assert handle_command("not_a_real_command", []) is False + + def test_activity_no_args_calls_generate(self, _log, _err, mock_con, mock_jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report", return_value="report") as mock_gen: + result = handle_command("activity", []) + + assert result is True + mock_gen.assert_called_once_with(since_hours=24.0, verbosity="normal") + mock_con.print.assert_called_with("report") + + def test_activity_help_shows_help(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report") as mock_gen: + result = handle_command("activity", ["--help"]) + + assert result is True + mock_gen.assert_not_called() + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("ACTIVITY" in c for c in calls) + + def test_activity_hours_48(self, _log, _err, _con, mock_jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report", return_value="report") as mock_gen: + result = handle_command("activity", ["--hours", "48"]) + + assert result is True + mock_gen.assert_called_once_with(since_hours=48.0, verbosity="normal") + + +# ============================================= +# handle_command -- activity-report +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.error") +@patch(f"{MODULE}.logger") +class TestActivityReportCommand: + """Tests for 'activity-report' command.""" + + def test_activity_report_no_args(self, _log, _err, _con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report", return_value="detailed") as mock_gen: + result = handle_command("activity-report", []) + + assert result is True + mock_gen.assert_called_once_with(since_hours=24.0, verbosity="detailed") + + def test_activity_report_help(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report") as mock_gen: + result = handle_command("activity-report", ["--help"]) + + assert result is True + mock_gen.assert_not_called() + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("ACTIVITY-REPORT" in c for c in calls) + + def test_activity_report_json(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.get_json_report", return_value={"branches": []}) as mock_json: + result = handle_command("activity-report", ["--json"]) + + assert result is True + mock_json.assert_called_once_with(24.0) + + def test_activity_report_json_short_flag(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.get_json_report", return_value={}) as mock_json: + result = handle_command("activity-report", ["-j"]) + + assert result is True + mock_json.assert_called_once() + + +# ============================================= +# handle_command -- activity_report alias +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.error") +@patch(f"{MODULE}.logger") +class TestActivityReportAlias: + """Tests for 'activity_report' underscore alias.""" + + def test_activity_report_alias_works(self, _log, _err, _con, mock_jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report", return_value="r") as mock_gen: + result = handle_command("activity_report", []) + + assert result is True + mock_gen.assert_called_once() + + def test_activity_report_alias_help(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + result = handle_command("activity_report", ["--help"]) + assert result is True + # Shows introspection (module info) + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("activity_report Module" in c for c in calls) + + +# ============================================= +# handle_command -- branch-health +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.error") +@patch(f"{MODULE}.logger") +class TestBranchHealthCommand: + """Tests for 'branch-health' command.""" + + def test_branch_health_no_args(self, _log, _err, _con, mock_jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_activity_report", return_value="all") as mock_gen: + result = handle_command("branch-health", []) + + assert result is True + mock_gen.assert_called_once_with(since_hours=24, verbosity="normal") + + def test_branch_health_help(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + result = handle_command("branch-health", ["--help"]) + assert result is True + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("BRANCH-HEALTH" in c for c in calls) + + def test_branch_health_with_branch(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_branch_report", return_value="DRONE report") as mock_br: + result = handle_command("branch-health", ["DRONE"]) + + assert result is True + mock_br.assert_called_once_with("DRONE", since_hours=24.0) + + def test_branch_health_with_branch_and_hours(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + with patch(f"{MODULE}.generate_branch_report", return_value="report") as mock_br: + result = handle_command("branch-health", ["DRONE", "--hours", "48"]) + + assert result is True + mock_br.assert_called_once_with("DRONE", since_hours=48.0) + + def test_branch_health_only_flags_shows_error(self, _log, mock_err, mock_con, _jh): + from aipass.daemon.apps.modules.activity_report import handle_command + + result = handle_command("branch-health", ["--hours", "48"]) + assert result is True + mock_err.assert_called() + + +# ============================================= +# _parse_hours_arg +# ============================================= + +@patch(f"{MODULE}.logger") +class TestParseHoursArg: + """Tests for _parse_hours_arg helper.""" + + def test_hours_flag(self, _log): + from aipass.daemon.apps.modules.activity_report import _parse_hours_arg + + assert _parse_hours_arg(["--hours", "48"]) == 48.0 + + def test_short_flag(self, _log): + from aipass.daemon.apps.modules.activity_report import _parse_hours_arg + + assert _parse_hours_arg(["-t", "12"]) == 12.0 + + def test_no_flag_returns_default(self, _log): + from aipass.daemon.apps.modules.activity_report import _parse_hours_arg + + assert _parse_hours_arg([]) == 24.0 + + def test_invalid_value_returns_default(self, mock_log): + from aipass.daemon.apps.modules.activity_report import _parse_hours_arg + + result = _parse_hours_arg(["--hours", "abc"]) + assert result == 24.0 + mock_log.warning.assert_called() + + +# ============================================= +# _extract_branch_name +# ============================================= + +class TestExtractBranchName: + """Tests for _extract_branch_name helper.""" + + def test_branch_with_flags(self): + from aipass.daemon.apps.modules.activity_report import _extract_branch_name + + assert _extract_branch_name(["DRONE", "--hours", "48"]) == "DRONE" + + def test_only_flags_returns_none(self): + from aipass.daemon.apps.modules.activity_report import _extract_branch_name + + assert _extract_branch_name(["--hours", "48"]) is None diff --git a/src/aipass/daemon/tests/test_schedule_module.py b/src/aipass/daemon/tests/test_schedule_module.py new file mode 100644 index 00000000..37a20b28 --- /dev/null +++ b/src/aipass/daemon/tests/test_schedule_module.py @@ -0,0 +1,304 @@ +# =================== AIPass ==================== +# Name: test_schedule_module.py +# Description: Tests for the schedule CLI module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for the schedule CLI module (apps/modules/schedule.py).""" + +from unittest.mock import patch, MagicMock + +MODULE = "aipass.daemon.apps.modules.schedule" + + +# ============================================= +# handle_command -- routing basics +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestHandleCommandRouting: + """Tests for handle_command routing.""" + + def test_wrong_command_returns_false(self, _log, _err, _con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + assert handle_command("not_schedule", []) is False + + def test_no_args_shows_introspection(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", []) + assert result is True + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("schedule Module" in c for c in calls) + + def test_help_flag(self, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["--help"]) + assert result is True + calls = [str(c) for c in mock_con.print.call_args_list] + assert any("USAGE" in c for c in calls) + + def test_unknown_subcommand(self, _log, mock_err, _con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["foobar"]) + assert result is False + mock_err.assert_called() + + +# ============================================= +# handle_command -- list subcommand +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestListSubcommand: + """Tests for 'schedule list' subcommand.""" + + @patch(f"{MODULE}.load_tasks", return_value=[]) + def test_list_success(self, mock_load, _log, _err, mock_con, mock_jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["list"]) + assert result is True + mock_load.assert_called_once() + + @patch(f"{MODULE}.load_tasks", side_effect=RuntimeError("disk error")) + def test_list_exception(self, _load, _log, mock_err, _con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["list"]) + assert result is False + mock_err.assert_called() + + +# ============================================= +# handle_command -- delete subcommand +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestDeleteSubcommand: + """Tests for 'schedule delete' subcommand.""" + + def test_delete_no_args_shows_error(self, _log, mock_err, _con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["delete"]) + assert result is False + mock_err.assert_called() + + @patch(f"{MODULE}.delete_task", return_value=True) + def test_delete_success(self, mock_del, _log, _err, _con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["delete", "abc123"]) + assert result is True + mock_del.assert_called_once_with("abc123") + + @patch(f"{MODULE}.delete_task", return_value=False) + def test_delete_not_found(self, mock_del, _log, mock_err, _con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["delete", "abc123"]) + assert result is False + mock_err.assert_called() + + +# ============================================= +# handle_command -- run-due subcommand +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestRunDueSubcommand: + """Tests for 'schedule run-due' subcommand.""" + + @patch(f"{MODULE}.process_due_tasks_batch", return_value={ + "recovered": 0, "due": 0, "success": 0, "failed": 0, "processed_tasks": [], + }) + @patch(f"{MODULE}.FILELOCK_AVAILABLE", False) + def test_run_due_without_lock(self, mock_batch, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["run-due"]) + assert result is True + mock_batch.assert_called_once() + + @patch(f"{MODULE}.process_due_tasks_batch", return_value={ + "recovered": 1, + "due": 2, + "success": 1, + "failed": 1, + "processed_tasks": [ + {"id": "a1", "recipient": "@flow", "task": "Check plan", "status": "sent"}, + {"id": "a2", "recipient": "@seedgo", "task": "Audit", "status": "failed"}, + ], + }) + @patch(f"{MODULE}.FILELOCK_AVAILABLE", False) + def test_run_due_processes_tasks(self, mock_batch, _log, _err, mock_con, _jh): + from aipass.daemon.apps.modules.schedule import handle_command + + result = handle_command("schedule", ["run-due"]) + assert result is True + mock_batch.assert_called_once() + calls = " ".join(str(c) for c in mock_con.print.call_args_list) + assert "1 sent" in calls + assert "1 failed" in calls + + +# ============================================= +# _handle_create +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestHandleCreate: + """Tests for _handle_create.""" + + @patch(f"{MODULE}.create_task", return_value={"id": "task-001"}) + @patch(f"{MODULE}.parse_due_date", return_value="2026-04-10") + def test_create_valid(self, _due, mock_create, _log, _err, _con): + from aipass.daemon.apps.modules.schedule import _handle_create + + result = _handle_create(["Follow up", "--due", "7d", "--to", "@flow"]) + assert result is True + mock_create.assert_called_once() + + def test_create_missing_task(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.schedule import _handle_create + + result = _handle_create(["--due", "7d", "--to", "@flow"]) + assert result is False + mock_err.assert_called() + + @patch(f"{MODULE}.parse_due_date", return_value=None) + def test_create_invalid_due(self, _due, _log, mock_err, _con): + from aipass.daemon.apps.modules.schedule import _handle_create + + result = _handle_create(["Task text", "--due", "xyz", "--to", "@flow"]) + assert result is False + mock_err.assert_called() + + +# ============================================= +# _process_due_tasks +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestProcessDueTasks: + """Tests for _process_due_tasks.""" + + @patch(f"{MODULE}.process_due_tasks_batch", return_value={ + "recovered": 0, "due": 0, "success": 0, "failed": 0, "processed_tasks": [], + }) + def test_no_tasks_due(self, mock_batch, _log, _err, mock_con): + from aipass.daemon.apps.modules.schedule import _process_due_tasks + + result = _process_due_tasks() + assert result is True + calls = " ".join(str(c) for c in mock_con.print.call_args_list) + assert "No tasks due" in calls + + @patch(f"{MODULE}.process_due_tasks_batch", return_value={ + "recovered": 0, + "due": 2, + "success": 1, + "failed": 1, + "processed_tasks": [ + {"id": "t1", "recipient": "@flow", "task": "Check", "status": "sent"}, + {"id": "t2", "recipient": "@seedgo", "task": "Audit", "status": "failed"}, + ], + }) + def test_mix_sent_failed(self, mock_batch, _log, _err, mock_con): + from aipass.daemon.apps.modules.schedule import _process_due_tasks + + result = _process_due_tasks() + assert result is True + calls = " ".join(str(c) for c in mock_con.print.call_args_list) + assert "1 sent" in calls + assert "1 failed" in calls + + +# ============================================= +# _display_task_result +# ============================================= + +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.cli_error") +@patch(f"{MODULE}.logger") +class TestDisplayTaskResult: + """Tests for _display_task_result per-status output.""" + + def test_status_sent(self, mock_log, _err, mock_con): + from aipass.daemon.apps.modules.schedule import _display_task_result + + _display_task_result({ + "id": "t1", "recipient": "@flow", "task": "Check plan", "status": "sent", + }) + calls = " ".join(str(c) for c in mock_con.print.call_args_list) + assert "OK" in calls or "Sent" in calls or "@flow" in calls + + def test_status_skipped(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.schedule import _display_task_result + + _display_task_result({ + "id": "t2", "recipient": "@seedgo", "task": "Audit", "status": "skipped", + }) + mock_err.assert_called() + + def test_status_failed(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.schedule import _display_task_result + + _display_task_result({ + "id": "t3", "recipient": "@vera", "task": "Heartbeat", "status": "failed", + }) + mock_err.assert_called() + + def test_status_error(self, _log, mock_err, _con): + from aipass.daemon.apps.modules.schedule import _display_task_result + + _display_task_result({ + "id": "t4", "recipient": "@drone", "task": "Ping", "status": "error", + "error": "timeout", + }) + mock_err.assert_called() + + +# ============================================= +# _send_email_via_drone +# ============================================= + +@patch(f"{MODULE}.logger") +class TestSendEmailViaDrone: + """Tests for _send_email_via_drone subprocess wrapper.""" + + @patch("subprocess.run") + def test_success(self, mock_run, _log): + from aipass.daemon.apps.modules.schedule import _send_email_via_drone + + mock_run.return_value = MagicMock(returncode=0) + assert _send_email_via_drone("@flow", "subj", "body") is True + mock_run.assert_called_once() + + @patch("subprocess.run", side_effect=OSError("no drone")) + def test_failure(self, _run, _log): + from aipass.daemon.apps.modules.schedule import _send_email_via_drone + + assert _send_email_via_drone("@flow", "subj", "body") is False diff --git a/src/aipass/daemon/tests/test_scheduler_cron.py b/src/aipass/daemon/tests/test_scheduler_cron.py new file mode 100644 index 00000000..b4b6ed3b --- /dev/null +++ b/src/aipass/daemon/tests/test_scheduler_cron.py @@ -0,0 +1,479 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_scheduler_cron.py - Scheduler Cron Tests +# Date: 2026-04-02 +# Version: 1.0.0 +# Category: daemon/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-04-02): Initial creation - scheduler_cron dispatch path tests +# +# CODE STANDARDS: +# - Pytest conventions +# - Full mock isolation (no real subprocesses or locks) +# ============================================= + +"""Tests for scheduler_cron dispatch paths.""" + +import subprocess +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +MODULE = "aipass.daemon.apps.scheduler_cron" + + +# ============================================= +# FIXTURES +# ============================================= + +def _make_task( + task_id: str = "abc12345-6789", + recipient: str = "@devpulse", + task: str = "Run morning briefing", + message: str = "Details here", +) -> dict: + """Build a minimal task dict for testing.""" + return { + "id": task_id, + "recipient": recipient, + "task": task, + "message": message, + } + + +@pytest.fixture(autouse=True) +def _silence_logging(): + """Suppress logger and console output for all tests.""" + with ( + patch(f"{MODULE}.logger"), + patch(f"{MODULE}.console"), + patch(f"{MODULE}.log"), + ): + yield + + +# ============================================= +# _send_email_via_drone +# ============================================= + +class TestSendEmailViaDrone: + """Tests for _send_email_via_drone subprocess wrapper.""" + + def test_success(self): + from aipass.daemon.apps.scheduler_cron import _send_email_via_drone + + mock_result = MagicMock(returncode=0) + with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run: + result = _send_email_via_drone("@devpulse", "Subject", "Body") + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd[:3] == ["drone", "@ai_mail", "send"] + assert "--dispatch" in cmd + + def test_no_auto_execute(self): + from aipass.daemon.apps.scheduler_cron import _send_email_via_drone + + mock_result = MagicMock(returncode=0) + with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run: + _send_email_via_drone("@devpulse", "Subj", "Msg", auto_execute=False) + cmd = mock_run.call_args[0][0] + assert "--dispatch" not in cmd + + def test_nonzero_returncode(self): + from aipass.daemon.apps.scheduler_cron import _send_email_via_drone + + mock_result = MagicMock(returncode=1) + with patch(f"{MODULE}.subprocess.run", return_value=mock_result): + result = _send_email_via_drone("@devpulse", "Subj", "Msg") + assert result is False + + def test_subprocess_error(self): + from aipass.daemon.apps.scheduler_cron import _send_email_via_drone + + with patch(f"{MODULE}.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="drone", timeout=15)): + result = _send_email_via_drone("@devpulse", "Subj", "Msg") + assert result is False + + def test_os_error(self): + from aipass.daemon.apps.scheduler_cron import _send_email_via_drone + + with patch(f"{MODULE}.subprocess.run", side_effect=OSError("drone not found")): + result = _send_email_via_drone("@devpulse", "Subj", "Msg") + assert result is False + + +# ============================================= +# _next_cron_run +# ============================================= + +class TestNextCronRun: + """Tests for next cron run time calculation.""" + + def test_before_half_hour(self): + from aipass.daemon.apps.scheduler_cron import _next_cron_run + + fake_now = datetime(2026, 4, 2, 10, 15, 0) + with patch(f"{MODULE}.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = _next_cron_run() + assert result == "10:30" + + def test_after_half_hour(self): + from aipass.daemon.apps.scheduler_cron import _next_cron_run + + fake_now = datetime(2026, 4, 2, 10, 45, 0) + with patch(f"{MODULE}.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = _next_cron_run() + assert result == "11:00" + + def test_before_midnight_rollover(self): + from aipass.daemon.apps.scheduler_cron import _next_cron_run + + fake_now = datetime(2026, 4, 2, 23, 45, 0) + with patch(f"{MODULE}.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + result = _next_cron_run() + assert result == "00:00" + + +# ============================================= +# _process_single_task +# ============================================= + +class TestProcessSingleTask: + """Tests for the single-task dispatch function.""" + + def test_success_path(self): + from aipass.daemon.apps.scheduler_cron import _process_single_task + + results = {"success": 0, "failed": 0, "errors": []} + task = _make_task() + + with ( + patch(f"{MODULE}.mark_dispatching") as mock_dispatch, + patch(f"{MODULE}.send_email_direct", return_value=True) as mock_send, + patch(f"{MODULE}.mark_completed") as mock_complete, + patch(f"{MODULE}.AI_MAIL_AVAILABLE", True), + ): + _process_single_task(task, results) + + mock_dispatch.assert_called_once_with(task["id"]) + mock_send.assert_called_once() + mock_complete.assert_called_once_with(task["id"]) + assert results["success"] == 1 + assert results["failed"] == 0 + + def test_mark_dispatching_failure(self): + from aipass.daemon.apps.scheduler_cron import _process_single_task + + results = {"success": 0, "failed": 0, "errors": []} + task = _make_task() + + with ( + patch(f"{MODULE}.mark_dispatching", side_effect=RuntimeError("lock error")), + patch(f"{MODULE}.send_email_direct") as mock_send, + ): + _process_single_task(task, results) + + mock_send.assert_not_called() + assert results["failed"] == 1 + assert len(results["errors"]) == 1 + + def test_email_unavailable(self): + from aipass.daemon.apps.scheduler_cron import _process_single_task + + results = {"success": 0, "failed": 0, "errors": []} + task = _make_task() + + with ( + patch(f"{MODULE}.mark_dispatching"), + patch(f"{MODULE}.AI_MAIL_AVAILABLE", False), + patch(f"{MODULE}.mark_pending") as mock_pending, + ): + _process_single_task(task, results) + + mock_pending.assert_called_once_with(task["id"]) + assert results["failed"] == 1 + + def test_email_send_returns_false(self): + from aipass.daemon.apps.scheduler_cron import _process_single_task + + results = {"success": 0, "failed": 0, "errors": []} + task = _make_task() + + with ( + patch(f"{MODULE}.mark_dispatching"), + patch(f"{MODULE}.send_email_direct", return_value=False), + patch(f"{MODULE}.mark_pending") as mock_pending, + patch(f"{MODULE}.AI_MAIL_AVAILABLE", True), + ): + _process_single_task(task, results) + + mock_pending.assert_called_once_with(task["id"]) + assert results["failed"] == 1 + assert results["success"] == 0 + + def test_email_exception_resets_to_pending(self): + from aipass.daemon.apps.scheduler_cron import _process_single_task + + results = {"success": 0, "failed": 0, "errors": []} + task = _make_task() + + with ( + patch(f"{MODULE}.mark_dispatching"), + patch(f"{MODULE}.send_email_direct", side_effect=ConnectionError("timeout")), + patch(f"{MODULE}.mark_pending") as mock_pending, + patch(f"{MODULE}.AI_MAIL_AVAILABLE", True), + ): + _process_single_task(task, results) + + mock_pending.assert_called_once_with(task["id"]) + assert results["failed"] == 1 + + +# ============================================= +# process_due_tasks +# ============================================= + +class TestProcessDueTasks: + """Tests for the top-level due-task processor.""" + + def test_no_tasks_due(self): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + with ( + patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True), + patch(f"{MODULE}.recover_stale_dispatches", return_value=0), + patch(f"{MODULE}.get_due_tasks", return_value=[]), + ): + results = process_due_tasks() + + assert results["due"] == 0 + assert results["success"] == 0 + + def test_task_registry_unavailable(self): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + with patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", False): + results = process_due_tasks() + + assert results["due"] == 0 + assert results["success"] == 0 + + def test_stale_dispatch_recovery(self): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + with ( + patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True), + patch(f"{MODULE}.recover_stale_dispatches", return_value=3) as mock_recover, + patch(f"{MODULE}.get_due_tasks", return_value=[]), + ): + results = process_due_tasks() + + mock_recover.assert_called_once_with(max_age_minutes=5) + assert results["recovered"] == 3 + + def test_stale_recovery_exception(self): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + with ( + patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True), + patch(f"{MODULE}.recover_stale_dispatches", side_effect=RuntimeError("fs error")), + patch(f"{MODULE}.get_due_tasks", return_value=[]), + ): + results = process_due_tasks() + + assert len(results["errors"]) == 1 + assert "Stale recovery" in results["errors"][0] + + def test_get_due_tasks_exception(self): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + with ( + patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True), + patch(f"{MODULE}.recover_stale_dispatches", return_value=0), + patch(f"{MODULE}.get_due_tasks", side_effect=RuntimeError("corrupt JSON")), + ): + results = process_due_tasks() + + assert "Load tasks" in results["errors"][0] + + @patch(f"{MODULE}.time.sleep") + def test_successful_send(self, _mock_sleep): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + task = _make_task() + with ( + patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True), + patch(f"{MODULE}.recover_stale_dispatches", return_value=0), + patch(f"{MODULE}.get_due_tasks", return_value=[task]), + patch(f"{MODULE}.mark_dispatching"), + patch(f"{MODULE}.send_email_direct", return_value=True), + patch(f"{MODULE}.mark_completed"), + patch(f"{MODULE}.AI_MAIL_AVAILABLE", True), + ): + results = process_due_tasks() + + assert results["due"] == 1 + assert results["success"] == 1 + assert results["failed"] == 0 + + @patch(f"{MODULE}.time.sleep") + def test_send_failure_marks_pending(self, _mock_sleep): + from aipass.daemon.apps.scheduler_cron import process_due_tasks + + task = _make_task() + with ( + patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True), + patch(f"{MODULE}.recover_stale_dispatches", return_value=0), + patch(f"{MODULE}.get_due_tasks", return_value=[task]), + patch(f"{MODULE}.mark_dispatching"), + patch(f"{MODULE}.send_email_direct", return_value=False), + patch(f"{MODULE}.mark_pending") as mock_pending, + patch(f"{MODULE}.AI_MAIL_AVAILABLE", True), + ): + results = process_due_tasks() + + mock_pending.assert_called_once() + assert results["failed"] == 1 + + +# ============================================= +# _run_locked +# ============================================= + +class TestRunLocked: + """Tests for the locked orchestration function.""" + + def test_success_no_errors(self): + from aipass.daemon.apps.scheduler_cron import _run_locked + + task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []} + action_results = { + "total": 0, "enabled": 0, "executed": 0, "failed": 0, + "errors": [], "executed_actions": [], "skipped_actions": [], + } + + with ( + patch(f"{MODULE}.process_due_tasks", return_value=task_results), + patch(f"{MODULE}.process_actions", return_value=action_results), + patch(f"{MODULE}._next_cron_run", return_value="10:30"), + ): + code = _run_locked() + + assert code == 0 + + def test_returns_1_on_task_failures(self): + from aipass.daemon.apps.scheduler_cron import _run_locked + + task_results = {"due": 1, "success": 0, "failed": 1, "recovered": 0, "errors": ["fail"]} + action_results = { + "total": 0, "enabled": 0, "executed": 0, "failed": 0, + "errors": [], "executed_actions": [], "skipped_actions": [], + } + + with ( + patch(f"{MODULE}.process_due_tasks", return_value=task_results), + patch(f"{MODULE}.process_actions", return_value=action_results), + patch(f"{MODULE}._next_cron_run", return_value="10:30"), + ): + code = _run_locked() + + assert code == 1 + + def test_process_due_tasks_unhandled_exception(self): + from aipass.daemon.apps.scheduler_cron import _run_locked + + with patch(f"{MODULE}.process_due_tasks", side_effect=RuntimeError("boom")): + code = _run_locked() + + assert code == 1 + + def test_process_actions_exception_handled(self): + from aipass.daemon.apps.scheduler_cron import _run_locked + + task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []} + + with ( + patch(f"{MODULE}.process_due_tasks", return_value=task_results), + patch(f"{MODULE}.process_actions", side_effect=RuntimeError("action boom")), + patch(f"{MODULE}._next_cron_run", return_value="10:30"), + ): + code = _run_locked() + + # The action error is caught but appended to errors, triggering exit 1 + assert code == 1 + + +# ============================================= +# main +# ============================================= + +class TestMain: + """Tests for the main entry point.""" + + def test_no_args_introspection(self): + from aipass.daemon.apps.scheduler_cron import main + + with ( + patch(f"{MODULE}.sys.argv", ["scheduler_cron.py"]), + patch(f"{MODULE}.print_introspection") as mock_intro, + ): + code = main() + + mock_intro.assert_called_once() + assert code == 0 + + def test_help_flag(self): + from aipass.daemon.apps.scheduler_cron import main + + with ( + patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "--help"]), + patch(f"{MODULE}.print_help") as mock_help, + ): + with pytest.raises(SystemExit) as exc_info: + main() + mock_help.assert_called_once() + assert exc_info.value.code == 0 + + def test_lock_acquisition_failure(self, tmp_path): + from aipass.daemon.apps.scheduler_cron import main + + lock_file = tmp_path / "schedule.lock" + mock_fd = MagicMock() + with ( + patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]), + patch(f"{MODULE}.json_handler"), + patch(f"{MODULE}.LOCK_FILE", lock_file), + patch("builtins.open", return_value=mock_fd), + patch(f"{MODULE}.fcntl.flock", side_effect=OSError("locked")), + ): + code = main() + + assert code == 0 # graceful skip when another instance is running + mock_fd.close.assert_called() + + def test_lock_acquired_runs_locked(self, tmp_path): + from aipass.daemon.apps.scheduler_cron import main + + lock_file = tmp_path / "schedule.lock" + mock_fd = MagicMock() + with ( + patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]), + patch(f"{MODULE}.json_handler"), + patch(f"{MODULE}.LOCK_FILE", lock_file), + patch("builtins.open", return_value=mock_fd), + patch(f"{MODULE}.fcntl.flock"), + patch(f"{MODULE}._run_locked", return_value=0) as mock_run, + ): + code = main() + + mock_run.assert_called_once() + assert code == 0 diff --git a/src/aipass/daemon/tests/test_scheduler_ops.py b/src/aipass/daemon/tests/test_scheduler_ops.py new file mode 100644 index 00000000..af916328 --- /dev/null +++ b/src/aipass/daemon/tests/test_scheduler_ops.py @@ -0,0 +1,114 @@ +# =================== AIPass ==================== +# Name: test_scheduler_ops.py +# Description: Tests for the scheduler_ops facade module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for the scheduler_ops facade module (apps/modules/scheduler_ops.py).""" + +from unittest.mock import patch + +MODULE = "aipass.daemon.apps.modules.scheduler_ops" + + +# ============================================= +# handle_command — routing +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.logger") +class TestHandleCommand: + """Tests for handle_command routing.""" + + def test_wrong_command_returns_false(self, _log, _con, _jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + assert handle_command("not-scheduler-ops", []) is False + + def test_no_args_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + result = handle_command("scheduler-ops", []) + assert result is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("scheduler_ops Module" in c for c in calls) + + def test_help_flag_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + assert handle_command("scheduler-ops", ["--help"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("scheduler_ops Module" in c for c in calls) + + def test_h_flag_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + assert handle_command("scheduler-ops", ["-h"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("scheduler_ops Module" in c for c in calls) + + def test_help_word_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + assert handle_command("scheduler-ops", ["help"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("scheduler_ops Module" in c for c in calls) + + def test_status_arg_shows_registry_info(self, _log, mock_console, mock_jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + assert handle_command("scheduler-ops", ["status"]) is True + mock_jh.log_operation.assert_called_once_with("scheduler_ops_status") + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("Scheduler Ops" in c for c in calls) + + def test_status_prints_task_registry_availability(self, _log, mock_console, mock_jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + handle_command("scheduler-ops", ["status"]) + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("Task registry" in c for c in calls) + + def test_status_prints_action_registry_availability(self, _log, mock_console, mock_jh): + from aipass.daemon.apps.modules.scheduler_ops import handle_command + + handle_command("scheduler-ops", ["status"]) + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("Action registry" in c for c in calls) + + +# ============================================= +# Module-level availability flags +# ============================================= + +class TestRegistryAvailability: + """Verify that registry imports succeed in the test environment.""" + + def test_task_registry_available(self): + from aipass.daemon.apps.modules.scheduler_ops import TASK_REGISTRY_AVAILABLE + + assert TASK_REGISTRY_AVAILABLE is True + + def test_action_registry_available(self): + from aipass.daemon.apps.modules.scheduler_ops import ACTION_REGISTRY_AVAILABLE + + assert ACTION_REGISTRY_AVAILABLE is True + + +# ============================================= +# print_introspection +# ============================================= + +@patch(f"{MODULE}.console") +class TestPrintIntrospection: + """Tests for print_introspection output.""" + + def test_prints_module_header(self, mock_console): + from aipass.daemon.apps.modules.scheduler_ops import print_introspection + + print_introspection() + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("scheduler_ops Module" in c for c in calls) diff --git a/src/aipass/daemon/tests/test_wakeup_ops.py b/src/aipass/daemon/tests/test_wakeup_ops.py new file mode 100644 index 00000000..dae42026 --- /dev/null +++ b/src/aipass/daemon/tests/test_wakeup_ops.py @@ -0,0 +1,94 @@ +# =================== AIPass ==================== +# Name: test_wakeup_ops.py +# Description: Tests for the wakeup_ops facade module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for the wakeup_ops facade module (apps/modules/wakeup_ops.py).""" + +from unittest.mock import patch + +MODULE = "aipass.daemon.apps.modules.wakeup_ops" + + +# ============================================= +# handle_command — routing +# ============================================= + +@patch(f"{MODULE}.json_handler") +@patch(f"{MODULE}.console") +@patch(f"{MODULE}.logger") +class TestHandleCommand: + """Tests for handle_command routing.""" + + def test_wrong_command_returns_false(self, _log, _con, _jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + assert handle_command("not-wakeup-ops", []) is False + + def test_no_args_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + result = handle_command("wakeup-ops", []) + assert result is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("wakeup_ops Module" in c for c in calls) + + def test_help_flag_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + assert handle_command("wakeup-ops", ["--help"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("wakeup_ops Module" in c for c in calls) + + def test_h_flag_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + assert handle_command("wakeup-ops", ["-h"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("wakeup_ops Module" in c for c in calls) + + def test_help_word_shows_introspection(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + assert handle_command("wakeup-ops", ["help"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("wakeup_ops Module" in c for c in calls) + + def test_status_arg_shows_info(self, _log, mock_console, mock_jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + assert handle_command("wakeup-ops", ["status"]) is True + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("Wakeup Ops" in c for c in calls) + + def test_status_calls_log_operation(self, _log, _con, mock_jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + handle_command("wakeup-ops", ["status"]) + mock_jh.log_operation.assert_called_once_with("wakeup_ops_status") + + def test_status_prints_notifications_archived(self, _log, mock_console, _jh): + from aipass.daemon.apps.modules.wakeup_ops import handle_command + + handle_command("wakeup-ops", ["status"]) + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("Notifications" in c for c in calls) + + +# ============================================= +# print_introspection +# ============================================= + +@patch(f"{MODULE}.console") +class TestPrintIntrospection: + """Tests for print_introspection output.""" + + def test_prints_module_header(self, mock_console): + from aipass.daemon.apps.modules.wakeup_ops import print_introspection + + print_introspection() + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("wakeup_ops Module" in c for c in calls) diff --git a/src/aipass/drone/tests/test_config.py b/src/aipass/drone/tests/test_config.py new file mode 100644 index 00000000..770bf08d --- /dev/null +++ b/src/aipass/drone/tests/test_config.py @@ -0,0 +1,249 @@ +"""Tests for the config module — registry configuration management.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +MODULE = "aipass.drone.apps.modules.config" + + +# =========================================================================== +# Fixtures +# =========================================================================== + + +@pytest.fixture(autouse=True) +def _isolate_config(): + """Patch all external dependencies so config.py never touches real state.""" + with ( + patch(f"{MODULE}.logger") as mock_logger, + patch(f"{MODULE}.console") as mock_console, + patch(f"{MODULE}.json_handler") as mock_jh, + patch(f"{MODULE}.get_registry_path") as mock_get, + patch(f"{MODULE}.set_registry_path") as mock_set, + patch(f"{MODULE}.reset_registry_path") as mock_reset, + ): + mock_get.return_value = "/fake/registry.json" + yield { + "logger": mock_logger, + "console": mock_console, + "json_handler": mock_jh, + "get_registry_path": mock_get, + "set_registry_path": mock_set, + "reset_registry_path": mock_reset, + } + + +@pytest.fixture() +def mocks(_isolate_config): + """Expose the patched mocks dict for tests that inspect calls.""" + return _isolate_config + + +# =========================================================================== +# 1. handle_command — None / introspection +# =========================================================================== + + +class TestIntrospection: + """command=None triggers introspection display.""" + + def test_none_command_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command=None) + assert result is True + + def test_none_command_calls_print_introspection(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + with patch(f"{MODULE}.print_introspection") as mock_intro: + handle_command(command=None) + mock_intro.assert_called_once() + + def test_none_command_no_args_triggers_introspection(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command=None, args=None) + assert result is True + + def test_none_command_empty_args_triggers_introspection(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command=None, args=[]) + assert result is True + + +# =========================================================================== +# 2. handle_command — help +# =========================================================================== + + +class TestHelp: + """--help and -h flags route to print_help.""" + + def test_help_flag_as_command_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="--help") + assert result is True + + def test_h_flag_as_command_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="-h") + assert result is True + + def test_help_flag_in_args_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="path", args=["--help"]) + assert result is True + + def test_h_flag_in_args_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="set", args=["-h"]) + assert result is True + + def test_help_does_not_log_operation(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="--help") + mocks["json_handler"].log_operation.assert_not_called() + + +# =========================================================================== +# 3. handle_command — path +# =========================================================================== + + +class TestPathCommand: + """'path' command shows the current registry path.""" + + def test_path_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="path") + assert result is True + + def test_path_calls_get_registry_path(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="path") + mocks["get_registry_path"].assert_called_once() + + def test_path_prints_registry_location(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="path") + printed = mocks["console"].print.call_args[0][0] + assert "/fake/registry.json" in printed + + def test_path_logs_operation(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="path") + mocks["json_handler"].log_operation.assert_called_once_with( + "handle_command", {"module": "config", "command": "path"} + ) + + +# =========================================================================== +# 4. handle_command — set +# =========================================================================== + + +class TestSetCommand: + """'set' command overrides the registry path.""" + + def test_set_with_arg_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="set", args=["/new/path.json"]) + assert result is True + + def test_set_calls_set_registry_path_with_correct_arg(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="set", args=["/custom/registry.json"]) + mocks["set_registry_path"].assert_called_once_with("/custom/registry.json") + + def test_set_prints_new_path(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="set", args=["/new/path.json"]) + printed = mocks["console"].print.call_args[0][0] + assert "/new/path.json" in printed + + def test_set_without_args_returns_false(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="set") + assert result is False + + def test_set_without_args_logs_warning(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="set") + mocks["logger"].warning.assert_called() + + def test_set_without_args_does_not_call_set_registry_path(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="set") + mocks["set_registry_path"].assert_not_called() + + +# =========================================================================== +# 5. handle_command — reset +# =========================================================================== + + +class TestResetCommand: + """'reset' command restores the default registry path.""" + + def test_reset_returns_true(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="reset") + assert result is True + + def test_reset_calls_reset_registry_path(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="reset") + mocks["reset_registry_path"].assert_called_once() + + def test_reset_prints_confirmation(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="reset") + printed = mocks["console"].print.call_args[0][0] + assert "reset" in printed.lower() or "default" in printed.lower() + + +# =========================================================================== +# 6. handle_command — unknown command +# =========================================================================== + + +class TestUnknownCommand: + """Unrecognized commands return False and log a warning.""" + + def test_unknown_command_returns_false(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + result = handle_command(command="bogus") + assert result is False + + def test_unknown_command_logs_warning(self, mocks): + from aipass.drone.apps.modules.config import handle_command + + handle_command(command="destroy") + mocks["logger"].warning.assert_called() + warning_msg = mocks["logger"].warning.call_args[0][0] + assert "unknown" in warning_msg.lower() or "destroy" in str(mocks["logger"].warning.call_args) diff --git a/src/aipass/drone/tests/test_generic_adapter.py b/src/aipass/drone/tests/test_generic_adapter.py new file mode 100644 index 00000000..361a22ab --- /dev/null +++ b/src/aipass/drone/tests/test_generic_adapter.py @@ -0,0 +1,314 @@ +# =================== AIPass ==================== +# Name: test_generic_adapter.py +# Description: Tests for generic_adapter.capture_main() +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for generic_adapter.capture_main(). + +Covers: +- stdout / stderr capture from a target module's ``main()`` +- exit_code from normal return, SystemExit(0), SystemExit(1), SystemExit(None) +- Exception handling (ValueError, ImportError) +- Non-int exit codes coerced to 1 +- sys.argv construction with various combinations +- sys.argv / sys.stdout / sys.stderr restoration after success and failure +- json_handler.log_operation is called with expected args +""" + +from __future__ import annotations + +import sys +import types +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +import pytest + +from aipass.drone.apps.handlers.generic_adapter import capture_main + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _mock_log_operation(): + """Prevent json_handler.log_operation from touching disk.""" + with patch( + "aipass.drone.apps.handlers.generic_adapter.json_handler" + ) as mock_jh: + mock_jh.log_operation = MagicMock() + yield + + +@pytest.fixture() +def _fake_module_factory( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[str, Callable[[], object]], None]: + """Return a helper that registers a fake module with a given ``main``.""" + + def _make(module_name: str, main_fn: Callable[[], object]) -> None: + mod = types.ModuleType(module_name) + mod.main = main_fn # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_name, mod) + + return _make + + +# --------------------------------------------------------------------------- +# stdout capture +# --------------------------------------------------------------------------- + + +class TestStdoutCapture: + """Verify that output written to stdout is captured.""" + + def test_stdout_printed_by_main(self, _fake_module_factory): + _fake_module_factory("fake_stdout", lambda: print("hello world") or 0) + result = capture_main("fake_stdout", "prog") + assert result["stdout"] == "hello world\n" + assert result["stderr"] == "" + assert result["exit_code"] == 0 + + def test_stdout_multiline(self, _fake_module_factory): + def _main(): + print("line1") + print("line2") + return 0 + + _fake_module_factory("fake_multi", _main) + result = capture_main("fake_multi", "prog") + assert result["stdout"] == "line1\nline2\n" + + +# --------------------------------------------------------------------------- +# stderr capture +# --------------------------------------------------------------------------- + + +class TestStderrCapture: + """Verify that output written to stderr is captured.""" + + def test_stderr_written_by_main(self, _fake_module_factory): + def _main(): + sys.stderr.write("warning msg") + return 0 + + _fake_module_factory("fake_stderr", _main) + result = capture_main("fake_stderr", "prog") + assert result["stderr"] == "warning msg" + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + +# --------------------------------------------------------------------------- +# exit_code handling +# --------------------------------------------------------------------------- + + +class TestExitCode: + """Various return / SystemExit / exception exit-code scenarios.""" + + def test_normal_return_int(self, _fake_module_factory): + _fake_module_factory("ret_int", lambda: 42) + result = capture_main("ret_int", "prog") + assert result["exit_code"] == 42 + + def test_normal_return_zero(self, _fake_module_factory): + _fake_module_factory("ret_zero", lambda: 0) + result = capture_main("ret_zero", "prog") + assert result["exit_code"] == 0 + + def test_system_exit_zero(self, _fake_module_factory): + def _main(): + raise SystemExit(0) + + _fake_module_factory("se0", _main) + result = capture_main("se0", "prog") + assert result["exit_code"] == 0 + + def test_system_exit_one(self, _fake_module_factory): + def _main(): + raise SystemExit(1) + + _fake_module_factory("se1", _main) + result = capture_main("se1", "prog") + assert result["exit_code"] == 1 + + def test_system_exit_none(self, _fake_module_factory): + def _main(): + raise SystemExit(None) + + _fake_module_factory("se_none", _main) + result = capture_main("se_none", "prog") + assert result["exit_code"] == 0 + + def test_non_int_exit_code_coerced_to_one(self, _fake_module_factory): + """main() returns a string -- adapter coerces to exit_code=1.""" + _fake_module_factory("ret_str", lambda: "oops") + result = capture_main("ret_str", "prog") + assert result["exit_code"] == 1 + + def test_none_return_coerced_to_one(self, _fake_module_factory): + """main() returns None (implicit) -- not an int, coerced to 1.""" + _fake_module_factory("ret_none", lambda: None) + result = capture_main("ret_none", "prog") + assert result["exit_code"] == 1 + + +# --------------------------------------------------------------------------- +# Exception handling +# --------------------------------------------------------------------------- + + +class TestExceptionHandling: + """Exceptions inside main() should be captured on stderr.""" + + def test_value_error_captured(self, _fake_module_factory): + def _main(): + raise ValueError("bad value") + + _fake_module_factory("raise_ve", _main) + result = capture_main("raise_ve", "prog") + assert result["exit_code"] == 1 + assert "bad value" in result["stderr"] + + def test_import_error_for_missing_module(self): + """Non-existent module triggers ImportError -> exit_code=1.""" + result = capture_main("totally.nonexistent.module", "prog") + assert result["exit_code"] == 1 + assert result["stderr"] != "" + + +# --------------------------------------------------------------------------- +# sys.argv construction +# --------------------------------------------------------------------------- + + +class TestArgvConstruction: + """Ensure sys.argv is assembled correctly from name/command/args.""" + + def test_name_only(self, _fake_module_factory): + captured_argv: list[list[str]] = [] + + def _main(): + captured_argv.append(list(sys.argv)) + return 0 + + _fake_module_factory("argv_name", _main) + capture_main("argv_name", "myprog") + assert captured_argv[0] == ["myprog"] + + def test_name_and_command(self, _fake_module_factory): + captured_argv: list[list[str]] = [] + + def _main(): + captured_argv.append(list(sys.argv)) + return 0 + + _fake_module_factory("argv_cmd", _main) + capture_main("argv_cmd", "myprog", command="run") + assert captured_argv[0] == ["myprog", "run"] + + def test_name_command_and_args(self, _fake_module_factory): + captured_argv: list[list[str]] = [] + + def _main(): + captured_argv.append(list(sys.argv)) + return 0 + + _fake_module_factory("argv_full", _main) + capture_main("argv_full", "myprog", command="run", args=["--verbose", "file.txt"]) + assert captured_argv[0] == ["myprog", "run", "--verbose", "file.txt"] + + def test_name_and_args_no_command(self, _fake_module_factory): + captured_argv: list[list[str]] = [] + + def _main(): + captured_argv.append(list(sys.argv)) + return 0 + + _fake_module_factory("argv_nocommand", _main) + capture_main("argv_nocommand", "myprog", args=["--flag"]) + assert captured_argv[0] == ["myprog", "--flag"] + + +# --------------------------------------------------------------------------- +# Restoration of sys.argv / stdout / stderr +# --------------------------------------------------------------------------- + + +class TestRestoration: + """sys.argv, sys.stdout, sys.stderr must be restored after call.""" + + def test_restoration_after_success(self, _fake_module_factory): + _fake_module_factory("ok_mod", lambda: 0) + + orig_argv = sys.argv + orig_stdout = sys.stdout + orig_stderr = sys.stderr + + capture_main("ok_mod", "prog") + + assert sys.argv is orig_argv + assert sys.stdout is orig_stdout + assert sys.stderr is orig_stderr + + def test_restoration_after_exception(self, _fake_module_factory): + def _main(): + raise RuntimeError("boom") + + _fake_module_factory("err_mod", _main) + + orig_argv = sys.argv + orig_stdout = sys.stdout + orig_stderr = sys.stderr + + capture_main("err_mod", "prog") + + assert sys.argv is orig_argv + assert sys.stdout is orig_stdout + assert sys.stderr is orig_stderr + + def test_restoration_after_system_exit(self, _fake_module_factory): + def _main(): + raise SystemExit(2) + + _fake_module_factory("exit_mod", _main) + + orig_argv = sys.argv + orig_stdout = sys.stdout + orig_stderr = sys.stderr + + capture_main("exit_mod", "prog") + + assert sys.argv is orig_argv + assert sys.stdout is orig_stdout + assert sys.stderr is orig_stderr + + +# --------------------------------------------------------------------------- +# json_handler.log_operation call +# --------------------------------------------------------------------------- + + +class TestLogOperation: + """Verify json_handler.log_operation is invoked with correct args.""" + + def test_log_operation_called(self, _fake_module_factory): + _fake_module_factory("log_mod", lambda: 0) + with patch( + "aipass.drone.apps.handlers.generic_adapter.json_handler" + ) as mock_jh: + capture_main("log_mod", "myprog", command="status") + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args + assert call_args[0][0] == "generic_adapter.capture_main" + payload = call_args[0][1] + assert payload["entry_point"] == "log_mod" + assert payload["name"] == "myprog" + assert payload["command"] == "status" diff --git a/src/aipass/drone/tests/test_module_registry.py b/src/aipass/drone/tests/test_module_registry.py new file mode 100644 index 00000000..10e0c1e0 --- /dev/null +++ b/src/aipass/drone/tests/test_module_registry.py @@ -0,0 +1,398 @@ +"""Tests for the module_registry orchestrator — handle_command routing.""" + +from __future__ import annotations + +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# Module path prefix for patching +# --------------------------------------------------------------------------- +_MOD = "aipass.drone.apps.modules.module_registry" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_module_info( + name: str = "testmod", + version: str = "1.0.0", + description: str = "A test module", + adapter_path: str = "aipass.test.adapter", +) -> object: + """Build a ModuleInfo for mocking.""" + from aipass.drone.apps.handlers.module_registry_handler import ModuleInfo + + return ModuleInfo( + name=name, + version=version, + description=description, + adapter_path=adapter_path, + ) + + +# =========================================================================== +# 1. No command (None) — introspection +# =========================================================================== + + +class TestHandleCommandNone: + """When command is None and args is empty, print_introspection is called.""" + + def test_none_command_calls_introspection(self) -> None: + """handle_command(None) calls print_introspection and returns True.""" + with patch(f"{_MOD}.print_introspection") as mock_intro: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command(None) + + assert result is True + mock_intro.assert_called_once() + + def test_none_command_no_args_calls_introspection(self) -> None: + """handle_command(None, None) calls print_introspection.""" + with patch(f"{_MOD}.print_introspection") as mock_intro: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command(None, None) + + assert result is True + mock_intro.assert_called_once() + + def test_none_command_empty_args_calls_introspection(self) -> None: + """handle_command(None, []) triggers introspection (falsy args, None command).""" + with patch(f"{_MOD}.print_introspection") as mock_intro: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command(None, []) + + assert result is True + mock_intro.assert_called_once() + + +# =========================================================================== +# 2. Help routing (--help / -h) +# =========================================================================== + + +class TestHandleCommandHelp: + """--help and -h as command or first arg route to print_help.""" + + def test_help_long_flag_as_command(self) -> None: + """handle_command('--help') calls print_help and returns True.""" + with patch(f"{_MOD}.print_help") as mock_help: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("--help") + + assert result is True + mock_help.assert_called_once() + + def test_help_short_flag_as_command(self) -> None: + """handle_command('-h') calls print_help and returns True.""" + with patch(f"{_MOD}.print_help") as mock_help: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("-h") + + assert result is True + mock_help.assert_called_once() + + def test_help_flag_in_args(self) -> None: + """handle_command('list', ['--help']) calls print_help.""" + with patch(f"{_MOD}.print_help") as mock_help: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("list", ["--help"]) + + assert result is True + mock_help.assert_called_once() + + def test_short_help_flag_in_args(self) -> None: + """handle_command('info', ['-h']) calls print_help.""" + with patch(f"{_MOD}.print_help") as mock_help: + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("info", ["-h"]) + + assert result is True + mock_help.assert_called_once() + + +# =========================================================================== +# 3. list command +# =========================================================================== + + +class TestHandleCommandList: + """'list' command iterates modules and prints with/without info.""" + + def test_list_with_module_info(self) -> None: + """Modules with info are printed as '@name description'.""" + info = _make_module_info(name="alpha", description="Alpha module") + + with ( + patch(f"{_MOD}.list_modules", return_value=["alpha"]), + patch(f"{_MOD}.get_module_info", return_value=info), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("list") + + assert result is True + mock_console.print.assert_called() + printed = mock_console.print.call_args[0][0] + assert "@alpha" in printed + assert "Alpha module" in printed + + def test_list_without_module_info(self) -> None: + """Modules without info are printed as '@name (not available)'.""" + with ( + patch(f"{_MOD}.list_modules", return_value=["broken"]), + patch(f"{_MOD}.get_module_info", return_value=None), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("list") + + assert result is True + printed = mock_console.print.call_args[0][0] + assert "@broken" in printed + assert "(not available)" in printed + + def test_list_multiple_modules(self) -> None: + """Multiple modules each get a print call.""" + info_a = _make_module_info(name="aaa", description="Module A") + info_b = _make_module_info(name="bbb", description="Module B") + + def side_effect(name: str) -> object: + return {"aaa": info_a, "bbb": info_b}.get(name) + + with ( + patch(f"{_MOD}.list_modules", return_value=["aaa", "bbb"]), + patch(f"{_MOD}.get_module_info", side_effect=side_effect), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("list") + + assert result is True + assert mock_console.print.call_count == 2 + + def test_list_empty_registry(self) -> None: + """Empty module list returns True with no print calls.""" + with ( + patch(f"{_MOD}.list_modules", return_value=[]), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("list") + + assert result is True + mock_console.print.assert_not_called() + + def test_list_logs_operation(self) -> None: + """'list' logs via json_handler before processing.""" + with ( + patch(f"{_MOD}.list_modules", return_value=[]), + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + handle_command("list") + + mock_jh.log_operation.assert_called_once_with( + "handle_command", {"module": "module_registry", "command": "list"} + ) + + +# =========================================================================== +# 4. info command +# =========================================================================== + + +class TestHandleCommandInfo: + """'info' command shows module metadata.""" + + def test_info_without_args_returns_false(self) -> None: + """'info' with no args logs a warning and returns False.""" + with ( + patch(f"{_MOD}.logger") as mock_logger, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("info") + + assert result is False + mock_logger.warning.assert_called() + + def test_info_unknown_module_returns_false(self) -> None: + """'info' for a non-existent module returns False.""" + with ( + patch(f"{_MOD}.get_module_info", return_value=None), + patch(f"{_MOD}.logger") as mock_logger, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("info", ["nonexistent"]) + + assert result is False + mock_logger.warning.assert_called() + + def test_info_valid_module_returns_true(self) -> None: + """'info' for a valid module prints metadata and returns True.""" + info = _make_module_info( + name="seedgo", + version="2.1.0", + description="Seedgo audit system", + ) + + with ( + patch(f"{_MOD}.get_module_info", return_value=info), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("info", ["seedgo"]) + + assert result is True + printed = mock_console.print.call_args[0][0] + assert "seedgo" in printed + assert "2.1.0" in printed + assert "Seedgo audit system" in printed + + def test_info_logs_operation(self) -> None: + """'info' logs via json_handler before processing.""" + with ( + patch(f"{_MOD}.get_module_info", return_value=None), + patch(f"{_MOD}.logger"), + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + handle_command("info", ["anything"]) + + mock_jh.log_operation.assert_called_once_with( + "handle_command", {"module": "module_registry", "command": "info"} + ) + + +# =========================================================================== +# 5. check command +# =========================================================================== + + +class TestHandleCommandCheck: + """'check' command reports whether a module is registered.""" + + def test_check_without_args_returns_false(self) -> None: + """'check' with no args logs a warning and returns False.""" + with ( + patch(f"{_MOD}.logger") as mock_logger, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("check") + + assert result is False + mock_logger.warning.assert_called() + + def test_check_registered_module(self) -> None: + """'check' for a registered module prints True status.""" + with ( + patch(f"{_MOD}.is_module", return_value=True), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("check", ["git"]) + + assert result is True + printed = mock_console.print.call_args[0][0] + assert "git" in printed + assert "True" in printed + + def test_check_unregistered_module(self) -> None: + """'check' for an unregistered module prints False status.""" + with ( + patch(f"{_MOD}.is_module", return_value=False), + patch(f"{_MOD}.console") as mock_console, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("check", ["fakemod"]) + + assert result is True + printed = mock_console.print.call_args[0][0] + assert "fakemod" in printed + assert "False" in printed + + def test_check_logs_operation(self) -> None: + """'check' logs via json_handler before processing.""" + with ( + patch(f"{_MOD}.is_module", return_value=False), + patch(f"{_MOD}.console"), + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + handle_command("check", ["anything"]) + + mock_jh.log_operation.assert_called_once_with( + "handle_command", {"module": "module_registry", "command": "check"} + ) + + +# =========================================================================== +# 6. Unknown command +# =========================================================================== + + +class TestHandleCommandUnknown: + """Unrecognized commands log a warning and return False.""" + + def test_unknown_command_returns_false(self) -> None: + """An unrecognized command returns False.""" + with ( + patch(f"{_MOD}.logger") as mock_logger, + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + result = handle_command("foobar") + + assert result is False + mock_logger.warning.assert_called() + warning_msg = mock_logger.warning.call_args[0][0] + assert "unknown command" in warning_msg.lower() + + def test_unknown_command_logs_operation(self) -> None: + """Even unknown commands get logged via json_handler.""" + with ( + patch(f"{_MOD}.logger"), + patch(f"{_MOD}.json_handler") as mock_jh, + ): + from aipass.drone.apps.modules.module_registry import handle_command + + handle_command("bogus") + + mock_jh.log_operation.assert_called_once_with( + "handle_command", {"module": "module_registry", "command": "bogus"} + ) diff --git a/src/aipass/flow/tests/test_mbank_template.py b/src/aipass/flow/tests/test_mbank_template.py new file mode 100644 index 00000000..f9d18e9f --- /dev/null +++ b/src/aipass/flow/tests/test_mbank_template.py @@ -0,0 +1,1098 @@ +"""Tests for mbank/process.py and template handler functions.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# =================================================================== +# 1. mbank/process.py — load_flow_registry +# =================================================================== + + +class TestLoadFlowRegistry: + + def test_loads_valid_registry(self, tmp_path): + """Load a valid JSON registry file and return its contents.""" + registry_data = { + "next_number": 3, + "plans": {"1": {"subject": "a", "status": "open"}}, + "last_updated": "2026-03-01", + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry_data), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ): + from aipass.flow.apps.handlers.mbank.process import load_flow_registry + + result = load_flow_registry() + + assert result["next_number"] == 3 + assert "1" in result["plans"] + assert result["plans"]["1"]["subject"] == "a" + + def test_loads_named_registry_file(self, tmp_path): + """When registry_file is given, load from FLOW_JSON_DIR / registry_file.""" + data = {"plans": {}, "next_number": 1} + (tmp_path / "dplan_registry.json").write_text( + json.dumps(data), encoding="utf-8" + ) + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ): + from aipass.flow.apps.handlers.mbank.process import load_flow_registry + + result = load_flow_registry(registry_file="dplan_registry.json") + + assert result["next_number"] == 1 + + def test_raises_when_file_missing(self, tmp_path): + """Raise Exception when registry file does not exist.""" + missing = tmp_path / "nonexistent.json" + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", missing + ): + from aipass.flow.apps.handlers.mbank.process import load_flow_registry + + with pytest.raises(Exception, match="Flow registry not found"): + load_flow_registry() + + def test_raises_on_invalid_json(self, tmp_path): + """Raise Exception when registry contains invalid JSON.""" + bad_file = tmp_path / "fplan_registry.json" + bad_file.write_text("{not valid json", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", bad_file + ): + from aipass.flow.apps.handlers.mbank.process import load_flow_registry + + with pytest.raises(Exception, match="Failed to load flow registry"): + load_flow_registry() + + +# =================================================================== +# 2. mbank/process.py — save_flow_registry +# =================================================================== + + +class TestSaveFlowRegistry: + + def test_saves_registry_with_last_updated(self, tmp_path): + """Save registry and verify last_updated is set.""" + reg_file = tmp_path / "fplan_registry.json" + data = {"next_number": 5, "plans": {}} + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ): + from aipass.flow.apps.handlers.mbank.process import save_flow_registry + + save_flow_registry(data) + + saved = json.loads(reg_file.read_text(encoding="utf-8")) + assert saved["next_number"] == 5 + assert "last_updated" in saved + # last_updated should be an ISO timestamp string + assert "T" in saved["last_updated"] + + def test_saves_to_named_file(self, tmp_path): + """When registry_file arg is given, save to that filename inside FLOW_JSON_DIR.""" + data = {"next_number": 1, "plans": {"1": {"status": "open"}}} + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ): + from aipass.flow.apps.handlers.mbank.process import save_flow_registry + + save_flow_registry(data, registry_file="dplan_registry.json") + + saved = json.loads( + (tmp_path / "dplan_registry.json").read_text(encoding="utf-8") + ) + assert saved["plans"]["1"]["status"] == "open" + + def test_raises_on_write_failure(self, tmp_path): + """Raise Exception when the target path is not writable.""" + bad_path = tmp_path / "no_such_dir" / "sub" / "fplan_registry.json" + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", + tmp_path / "no_such_dir" / "sub", + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", bad_path + ): + from aipass.flow.apps.handlers.mbank.process import save_flow_registry + + with pytest.raises(Exception, match="Failed to save flow registry"): + save_flow_registry({"plans": {}}) + + +# =================================================================== +# 3. mbank/process.py — get_closed_plans +# =================================================================== + + +class TestGetClosedPlans: + + def test_returns_closed_unprocessed_plans(self, tmp_path): + """Return only closed, unprocessed plans whose files exist.""" + plan_file = tmp_path / "FPLAN-0002.md" + plan_file.write_text("closed plan content", encoding="utf-8") + + registry = { + "plans": { + "1": {"status": "open", "file_path": str(tmp_path / "FPLAN-0001.md")}, + "2": { + "status": "closed", + "file_path": str(plan_file), + }, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + return_value={"orphans_found": 0, "successfully_healed": 0, + "failed_to_heal": 0, "orphans": []}, + ): + from aipass.flow.apps.handlers.mbank.process import get_closed_plans + + result = get_closed_plans() + + assert len(result) == 1 + assert result[0]["number"] == "2" + assert result[0]["path"] == plan_file + + def test_skips_already_processed(self, tmp_path): + """Plans with processed=True are excluded.""" + plan_file = tmp_path / "FPLAN-0005.md" + plan_file.write_text("done", encoding="utf-8") + + registry = { + "plans": { + "5": { + "status": "closed", + "processed": True, + "file_path": str(plan_file), + }, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + return_value={"orphans_found": 0, "successfully_healed": 0, + "failed_to_heal": 0, "orphans": []}, + ): + from aipass.flow.apps.handlers.mbank.process import get_closed_plans + + result = get_closed_plans() + + assert len(result) == 0 + + def test_skips_missing_files(self, tmp_path): + """Closed plans whose file does not exist on disk are excluded.""" + registry = { + "plans": { + "9": { + "status": "closed", + "file_path": str(tmp_path / "FPLAN-0009-ghost.md"), + }, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + return_value={"orphans_found": 0, "successfully_healed": 0, + "failed_to_heal": 0, "orphans": []}, + ): + from aipass.flow.apps.handlers.mbank.process import get_closed_plans + + result = get_closed_plans() + + assert len(result) == 0 + + def test_calls_verify_and_heal(self, tmp_path): + """get_closed_plans calls verify_and_heal_orphaned_plans internally.""" + registry = {"plans": {}} + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + mock_heal = MagicMock( + return_value={"orphans_found": 0, "successfully_healed": 0, + "failed_to_heal": 0, "orphans": []} + ) + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ), patch( + "aipass.flow.apps.handlers.mbank.process.verify_and_heal_orphaned_plans", + mock_heal, + ): + from aipass.flow.apps.handlers.mbank.process import get_closed_plans + + get_closed_plans() + + mock_heal.assert_called_once() + + +# =================================================================== +# 4. mbank/process.py — cleanup_temp_files +# =================================================================== + + +class TestCleanupTempFiles: + + def test_deletes_temp_files(self, tmp_path): + """Delete -TEMP- files from MEMORY_PATH and report counts.""" + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + (memory_dir / "some-TEMP-20260301.md").write_text("t", encoding="utf-8") + (memory_dir / "other-TEMP-20260302.md").write_text("t", encoding="utf-8") + (memory_dir / "real-plan-20260303.md").write_text("keep", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir + ): + from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files + + result = cleanup_temp_files() + + assert result["files_found"] == 2 + assert result["files_deleted"] == 2 + assert result["failed_deletes"] == 0 + # Real plan file survives + assert (memory_dir / "real-plan-20260303.md").exists() + + def test_no_temp_files_found(self, tmp_path): + """Return zeros when no -TEMP- files exist.""" + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + (memory_dir / "normal-plan.md").write_text("ok", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir + ): + from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files + + result = cleanup_temp_files() + + assert result["files_found"] == 0 + assert result["files_deleted"] == 0 + + def test_handles_missing_memory_path(self, tmp_path): + """Return zeros when MEMORY_PATH does not exist.""" + nonexistent = tmp_path / "no_such_dir" + + with patch( + "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", nonexistent + ): + from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files + + result = cleanup_temp_files() + + assert result["files_found"] == 0 + assert result["files_deleted"] == 0 + + def test_reports_failed_deletes(self, tmp_path): + """Report failed_deletes when unlink raises.""" + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + temp_file = memory_dir / "broken-TEMP-20260301.md" + temp_file.write_text("t", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.MEMORY_PATH", memory_dir + ), patch.object( + Path, "unlink", side_effect=PermissionError("denied") + ): + from aipass.flow.apps.handlers.mbank.process import cleanup_temp_files + + result = cleanup_temp_files() + + assert result["files_found"] == 1 + assert result["failed_deletes"] == 1 + assert result["details"][0]["status"] == "delete_failed" + + +# =================================================================== +# 5. mbank/process.py — verify_and_heal_orphaned_plans +# =================================================================== + + +class TestVerifyAndHealOrphanedPlans: + + def test_heals_orphaned_closed_plan(self, tmp_path): + """Move a closed plan file to processed_plans and report as healed.""" + plan_file = tmp_path / "FPLAN-0010.md" + plan_file.write_text("orphan content", encoding="utf-8") + processed_dir = tmp_path / "processed" + + registry = { + "plans": { + "10": { + "status": "closed", + "file_path": str(plan_file), + }, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + processed_dir, + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ): + from aipass.flow.apps.handlers.mbank.process import ( + verify_and_heal_orphaned_plans, + ) + + result = verify_and_heal_orphaned_plans() + + assert result["orphans_found"] == 1 + assert result["successfully_healed"] == 1 + assert result["failed_to_heal"] == 0 + # Original file should be gone, destination should exist + assert not plan_file.exists() + assert (processed_dir / "FPLAN-0010.md").exists() + + def test_no_orphans_when_all_open(self, tmp_path): + """Open plans are never considered orphans.""" + registry = { + "plans": { + "1": {"status": "open", "file_path": str(tmp_path / "FPLAN-0001.md")}, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + tmp_path / "processed", + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ): + from aipass.flow.apps.handlers.mbank.process import ( + verify_and_heal_orphaned_plans, + ) + + result = verify_and_heal_orphaned_plans() + + assert result["orphans_found"] == 0 + + def test_skips_closed_plan_with_missing_file(self, tmp_path): + """Closed plan whose file is already gone is not an orphan.""" + registry = { + "plans": { + "7": { + "status": "closed", + "file_path": str(tmp_path / "FPLAN-0007-gone.md"), + }, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + tmp_path / "processed", + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ): + from aipass.flow.apps.handlers.mbank.process import ( + verify_and_heal_orphaned_plans, + ) + + result = verify_and_heal_orphaned_plans() + + assert result["orphans_found"] == 0 + + def test_handles_duplicate_destination(self, tmp_path): + """When destination already exists, append timestamp to avoid collision.""" + plan_file = tmp_path / "FPLAN-0020.md" + plan_file.write_text("orphan", encoding="utf-8") + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + # Pre-create a collision at the destination + (processed_dir / "FPLAN-0020.md").write_text("already there", encoding="utf-8") + + registry = { + "plans": { + "20": {"status": "closed", "file_path": str(plan_file)}, + } + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.mbank.process.FLOW_JSON_DIR", tmp_path + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ), patch( + "aipass.flow.apps.handlers.mbank.process.PROCESSED_PLANS_DIR", + processed_dir, + ), patch( + "aipass.flow.apps.handlers.mbank.process._get_all_registry_files", + return_value=["fplan_registry.json"], + ): + from aipass.flow.apps.handlers.mbank.process import ( + verify_and_heal_orphaned_plans, + ) + + result = verify_and_heal_orphaned_plans() + + assert result["orphans_found"] == 1 + assert result["successfully_healed"] == 1 + assert not plan_file.exists() + # Original collision file still present, plus the timestamped one + assert (processed_dir / "FPLAN-0020.md").exists() + # At least two files in processed dir now + md_files = list(processed_dir.glob("FPLAN-0020*.md")) + assert len(md_files) == 2 + + +# =================================================================== +# 6. mbank/process.py — process_closed_plans +# =================================================================== + + +class TestProcessClosedPlans: + + def test_process_no_closed_plans(self, tmp_path): + """When no closed plans exist, return success with zero processed.""" + with patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=[], + ), patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + return_value={ + "files_found": 0, "files_deleted": 0, + "failed_deletes": 0, "details": [], + }, + ): + from aipass.flow.apps.handlers.mbank.process import process_closed_plans + + result = process_closed_plans() + + assert result["success"] is True + assert result["processed"] == 0 + assert result["errors"] == 0 + + def test_process_single_plan_success(self, tmp_path): + """Archive one closed plan, update registry, report processed=1.""" + plan_file = tmp_path / "FPLAN-0042.md" + plan_file.write_text("content", encoding="utf-8") + + registry = { + "plans": { + "42": { + "status": "closed", + "file_path": str(plan_file), + }, + }, + "last_updated": "2026-01-01", + } + reg_file = tmp_path / "fplan_registry.json" + reg_file.write_text(json.dumps(registry), encoding="utf-8") + + closed_plans = [ + { + "number": "42", + "path": plan_file, + "info": registry["plans"]["42"], + "registry_file": "fplan_registry.json", + } + ] + + with patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=closed_plans, + ), patch( + "aipass.flow.apps.handlers.mbank.process.archive_plan", + return_value=True, + ) as mock_archive, patch( + "aipass.flow.apps.handlers.mbank.process.load_flow_registry", + return_value=registry, + ), patch( + "aipass.flow.apps.handlers.mbank.process.save_flow_registry", + ) as mock_save, patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + return_value={ + "files_found": 0, "files_deleted": 0, + "failed_deletes": 0, "details": [], + }, + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", reg_file + ): + from aipass.flow.apps.handlers.mbank.process import process_closed_plans + + result = process_closed_plans() + + assert result["success"] is True + assert result["processed"] == 1 + assert result["errors"] == 0 + mock_archive.assert_called_once_with(plan_file) + mock_save.assert_called_once() + # Verify registry was updated with processed=True + assert registry["plans"]["42"]["processed"] is True + + def test_process_plan_archive_failure(self, tmp_path): + """When archive_plan returns False, plan is counted as error.""" + plan_file = tmp_path / "FPLAN-0055.md" + plan_file.write_text("content", encoding="utf-8") + + registry = { + "plans": { + "55": { + "status": "closed", + "file_path": str(plan_file), + }, + }, + "last_updated": "2026-01-01", + } + + closed_plans = [ + { + "number": "55", + "path": plan_file, + "info": registry["plans"]["55"], + "registry_file": "fplan_registry.json", + } + ] + + with patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=closed_plans, + ), patch( + "aipass.flow.apps.handlers.mbank.process.archive_plan", + return_value=False, + ), patch( + "aipass.flow.apps.handlers.mbank.process.load_flow_registry", + return_value=registry, + ), patch( + "aipass.flow.apps.handlers.mbank.process.save_flow_registry", + ), patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + return_value={ + "files_found": 0, "files_deleted": 0, + "failed_deletes": 0, "details": [], + }, + ), patch( + "aipass.flow.apps.handlers.mbank.process.REGISTRY_FILE", + tmp_path / "fplan_registry.json", + ): + from aipass.flow.apps.handlers.mbank.process import process_closed_plans + + result = process_closed_plans() + + assert result["success"] is True + assert result["processed"] == 0 + assert result["errors"] == 1 + assert result["results"][0]["status"] == "archive_failed" + + def test_process_handles_exception(self, tmp_path): + """Top-level exception produces success=False response.""" + with patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + side_effect=RuntimeError("unexpected boom"), + ): + from aipass.flow.apps.handlers.mbank.process import process_closed_plans + + result = process_closed_plans() + + assert result["success"] is False + assert "unexpected boom" in result["error"] + + def test_process_calls_cleanup(self, tmp_path): + """cleanup_temp_files is called after processing closed plans.""" + mock_cleanup = MagicMock(return_value={ + "files_found": 1, "files_deleted": 1, + "failed_deletes": 0, "details": [], + }) + + with patch( + "aipass.flow.apps.handlers.mbank.process.get_closed_plans", + return_value=[], + ), patch( + "aipass.flow.apps.handlers.mbank.process.cleanup_temp_files", + mock_cleanup, + ): + from aipass.flow.apps.handlers.mbank.process import process_closed_plans + + result = process_closed_plans() + + mock_cleanup.assert_called_once() + assert result["cleanup"]["files_deleted"] == 1 + + +# =================================================================== +# 7. template/get_template.py — get_template +# =================================================================== + + +class TestGetTemplate: + + def test_loads_template_by_name(self, tmp_path): + """Load a template by name from the templates directory.""" + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + (templates_dir / "default.md").write_text( + "# Plan {plan_number}\nSubject: {subject}\nDate: {today}\n" + "Location: {location}\nTag: {tag}", + encoding="utf-8", + ) + + with patch( + "aipass.flow.apps.handlers.template.get_template.TEMPLATES_DIR", + templates_dir, + ): + from aipass.flow.apps.handlers.template.get_template import get_template + + result = get_template( + "default", number=1, location="flow", subject="Test" + ) + + assert "FPLAN-0001" in result + assert "Test" in result + assert "flow" in result + + def test_uses_template_path_override(self, tmp_path): + """When template_path is given, load from that file directly.""" + custom = tmp_path / "custom.md" + custom.write_text( + "Custom: {plan_number} - {subject} ({today}){tag}", + encoding="utf-8", + ) + + from aipass.flow.apps.handlers.template.get_template import get_template + + result = get_template( + template_path=custom, number=7, subject="Override", prefix="DPLAN" + ) + + assert "DPLAN-0007" in result + assert "Override" in result + + def test_custom_prefix_and_digits(self, tmp_path): + """Verify custom prefix and digit count in plan_number.""" + tpl = tmp_path / "tpl.md" + tpl.write_text("{plan_number}{tag}", encoding="utf-8") + + from aipass.flow.apps.handlers.template.get_template import get_template + + result = get_template( + template_path=tpl, number=3, prefix="XPLAN", digits=6 + ) + + assert result == "XPLAN-000003" + + def test_raises_file_not_found(self, tmp_path): + """FileNotFoundError when template does not exist.""" + empty_dir = tmp_path / "templates" + empty_dir.mkdir() + + with patch( + "aipass.flow.apps.handlers.template.get_template.TEMPLATES_DIR", + empty_dir, + ): + from aipass.flow.apps.handlers.template.get_template import get_template + + with pytest.raises(FileNotFoundError, match="not found"): + get_template("nonexistent") + + def test_today_placeholder_filled(self, tmp_path): + """The {today} placeholder is replaced with a date string.""" + tpl = tmp_path / "dated.md" + tpl.write_text("Date: {today}{tag}", encoding="utf-8") + + from aipass.flow.apps.handlers.template.get_template import get_template + + result = get_template(template_path=tpl, number=1) + + # Should contain a date like 2026-04-03 + assert len(result) > len("Date: ") + # The date portion should match YYYY-MM-DD format + date_part = result.replace("Date: ", "") + assert len(date_part) == 10 + assert date_part[4] == "-" and date_part[7] == "-" + + +# =================================================================== +# 8. template/plan_type_loader.py — discover_plan_types +# =================================================================== + + +class TestDiscoverPlanTypes: + + def test_discovers_plan_types_from_filesystem(self, tmp_path): + """Discover plan types from subdirectories with .md files.""" + templates_dir = tmp_path / "templates" + flow_dir = templates_dir / "flow_plans" + flow_dir.mkdir(parents=True) + (flow_dir / "default.md").write_text("template", encoding="utf-8") + (flow_dir / "master.md").write_text("master tpl", encoding="utf-8") + + dev_dir = templates_dir / "dev_plans" + dev_dir.mkdir() + (dev_dir / "default.md").write_text("dev template", encoding="utf-8") + + prefix_map = {"flow_plans": "FPLAN", "dev_plans": "DPLAN"} + + with patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value=prefix_map, + ): + from aipass.flow.apps.handlers.template.plan_type_loader import ( + discover_plan_types, + ) + + # Reset cache to force fresh scan + import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None + + result = discover_plan_types() + + assert "flow_plans" in result + assert "dev_plans" in result + assert result["flow_plans"]["prefix"] == "FPLAN" + assert result["dev_plans"]["prefix"] == "DPLAN" + assert "default" in result["flow_plans"]["available_templates"] + assert "master" in result["flow_plans"]["available_templates"] + assert result["flow_plans"]["registry_file"] == "fplan_registry.json" + + def test_skips_hidden_directories(self, tmp_path): + """Directories starting with . or _ are skipped.""" + templates_dir = tmp_path / "templates" + hidden = templates_dir / ".hidden" + hidden.mkdir(parents=True) + (hidden / "default.md").write_text("nope", encoding="utf-8") + + underscore = templates_dir / "_internal" + underscore.mkdir() + (underscore / "default.md").write_text("nope", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value={}, + ): + from aipass.flow.apps.handlers.template.plan_type_loader import ( + discover_plan_types, + ) + + import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None + + result = discover_plan_types() + + assert ".hidden" not in result + assert "_internal" not in result + + def test_skips_dirs_without_md_files(self, tmp_path): + """Directories with no .md files are skipped.""" + templates_dir = tmp_path / "templates" + empty_type = templates_dir / "empty_plans" + empty_type.mkdir(parents=True) + (empty_type / "readme.txt").write_text("not a template", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value={"empty_plans": "EPLAN"}, + ): + from aipass.flow.apps.handlers.template.plan_type_loader import ( + discover_plan_types, + ) + + import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None + + result = discover_plan_types() + + assert "empty_plans" not in result + + def test_skips_dirs_without_prefix_mapping(self, tmp_path): + """Directories not in PREFIX_MAP are skipped with a warning.""" + templates_dir = tmp_path / "templates" + unknown = templates_dir / "unknown_plans" + unknown.mkdir(parents=True) + (unknown / "default.md").write_text("tpl", encoding="utf-8") + + with patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + templates_dir, + ), patch( + "aipass.flow.apps.handlers.template.plan_type_loader._get_prefix_map", + return_value={}, + ): + from aipass.flow.apps.handlers.template.plan_type_loader import ( + discover_plan_types, + ) + + import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None + + result = discover_plan_types() + + assert "unknown_plans" not in result + + def test_returns_empty_when_no_templates_dir(self, tmp_path): + """Return empty dict when templates directory does not exist.""" + with patch( + "aipass.flow.apps.handlers.template.plan_type_loader.PLAN_TYPES_DIR", + tmp_path / "nonexistent", + ): + from aipass.flow.apps.handlers.template.plan_type_loader import ( + discover_plan_types, + ) + + import aipass.flow.apps.handlers.template.plan_type_loader as loader + loader._plan_type_cache = None + + result = discover_plan_types() + + assert result == {} + + +# =================================================================== +# 9. template/registry_ops.py — prefix_exists +# =================================================================== + + +class TestPrefixExists: + + def test_existing_prefix_returns_true(self, tmp_path): + """prefix_exists returns True for a registered prefix.""" + registry = { + "types": { + "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, + }, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", + "type_count": 1}, + } + reg_path = tmp_path / "template_registry.json" + reg_path.write_text(json.dumps(registry), encoding="utf-8") + + # Also create the templates dir so auto-heal does not prune + templates_dir = tmp_path / "templates" / "flow_plans" + templates_dir.mkdir(parents=True) + + with patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), patch( + "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + ): + from aipass.flow.apps.handlers.template.registry_ops import prefix_exists + + assert prefix_exists("FPLAN") is True + + def test_case_insensitive_match(self, tmp_path): + """prefix_exists is case-insensitive.""" + registry = { + "types": { + "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, + }, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", + "type_count": 1}, + } + reg_path = tmp_path / "template_registry.json" + reg_path.write_text(json.dumps(registry), encoding="utf-8") + + templates_dir = tmp_path / "templates" / "flow_plans" + templates_dir.mkdir(parents=True) + + with patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), patch( + "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + ): + from aipass.flow.apps.handlers.template.registry_ops import prefix_exists + + assert prefix_exists("fplan") is True + + def test_nonexistent_prefix_returns_false(self, tmp_path): + """prefix_exists returns False for an unregistered prefix.""" + registry = { + "types": { + "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, + }, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", + "type_count": 1}, + } + reg_path = tmp_path / "template_registry.json" + reg_path.write_text(json.dumps(registry), encoding="utf-8") + + templates_dir = tmp_path / "templates" / "flow_plans" + templates_dir.mkdir(parents=True) + + with patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), patch( + "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + ): + from aipass.flow.apps.handlers.template.registry_ops import prefix_exists + + assert prefix_exists("ZPLAN") is False + + +# =================================================================== +# 10. template/registry_ops.py — get_prefix_map +# =================================================================== + + +class TestGetPrefixMap: + + def test_returns_correct_mapping(self, tmp_path): + """get_prefix_map returns {dir_name: prefix} for all registered types.""" + registry = { + "types": { + "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, + "dev_plans": {"prefix": "DPLAN", "shorthand": "dplan"}, + }, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", + "type_count": 2}, + } + reg_path = tmp_path / "template_registry.json" + reg_path.write_text(json.dumps(registry), encoding="utf-8") + + # Create template directories so auto-heal does not prune + for name in ("flow_plans", "dev_plans"): + (tmp_path / "templates" / name).mkdir(parents=True) + + with patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), patch( + "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + ): + from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map + + result = get_prefix_map() + + assert result == {"flow_plans": "FPLAN", "dev_plans": "DPLAN"} + + def test_skips_entries_without_prefix_key(self, tmp_path): + """Entries missing the 'prefix' key are excluded from the map.""" + registry = { + "types": { + "flow_plans": {"prefix": "FPLAN", "shorthand": "fplan"}, + "broken_type": {"shorthand": "broken"}, + }, + "metadata": {"version": "1.0.0", "last_updated": "2026-03-18", + "type_count": 2}, + } + reg_path = tmp_path / "template_registry.json" + reg_path.write_text(json.dumps(registry), encoding="utf-8") + + for name in ("flow_plans", "broken_type"): + (tmp_path / "templates" / name).mkdir(parents=True) + + with patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), patch( + "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + ): + from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map + + result = get_prefix_map() + + assert "broken_type" not in result + assert result == {"flow_plans": "FPLAN"} + + def test_auto_creates_registry_when_missing(self, tmp_path): + """When registry file does not exist, get_prefix_map returns defaults.""" + reg_path = tmp_path / "flow_json" / "template_registry.json" + + # Create protected template directories so auto-heal does not prune + for name in ("flow_plans", "dev_plans"): + (tmp_path / "templates" / name).mkdir(parents=True) + + with patch( + "aipass.flow.apps.handlers.template.registry_ops.REGISTRY_PATH", + reg_path, + ), patch( + "aipass.flow.apps.handlers.template.registry_ops.FLOW_ROOT", tmp_path + ): + from aipass.flow.apps.handlers.template.registry_ops import get_prefix_map + + result = get_prefix_map() + + assert "flow_plans" in result + assert result["flow_plans"] == "FPLAN" + assert "dev_plans" in result + assert result["dev_plans"] == "DPLAN" + # Registry file should now exist + assert reg_path.exists() diff --git a/src/aipass/flow/tests/test_monitor_registry.py b/src/aipass/flow/tests/test_monitor_registry.py new file mode 100644 index 00000000..e71ef98a --- /dev/null +++ b/src/aipass/flow/tests/test_monitor_registry.py @@ -0,0 +1,774 @@ +"""Tests for monitor_ops handler and registry_monitor module.""" + +import builtins +import os +import time +import types +from collections.abc import Mapping, Sequence +from pathlib import Path +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest + + +# ─── Import helpers ─────────────────────────────────────── + + +def _import_monitor_ops(): + """Import monitor_ops module and return it.""" + import aipass.flow.apps.handlers.registry.monitor_ops as mod + return mod + + +def _import_registry_monitor(): + """Import registry_monitor module and return it.""" + import aipass.flow.apps.modules.registry_monitor as mod + return mod + + +def _make_plan_file(directory: Path, number: str) -> Path: + """Create a FPLAN-NNNN.md file in the given directory.""" + filename = f"FPLAN-{number}.md" + plan_file = directory / filename + plan_file.write_text(f"# Plan {number}\nTest content", encoding="utf-8") + return plan_file + + +def _make_event(src_path: str, dest_path: str | None = None, is_directory: bool = False): + """Create a mock watchdog event object.""" + event = MagicMock() + event.src_path = src_path + event.is_directory = is_directory + if dest_path is not None: + event.dest_path = dest_path + return event + + +# ═══════════════════════════════════════════════════════════ +# 1. handle_walk_error +# ═══════════════════════════════════════════════════════════ + + +class TestHandleWalkError: + """Tests for the handle_walk_error inner function in scan_plan_files_impl.""" + + def test_permission_error_is_silenced(self, tmp_path): + """PermissionError should not trigger a warning log.""" + mod = _import_monitor_ops() + with patch.object(mod, "_fire_event", return_value=False): + # The handle_walk_error function is defined inside scan_plan_files_impl. + # We exercise it by creating a directory we cannot read. + restricted = tmp_path / "restricted" + restricted.mkdir() + # Create a plan file in a readable subdirectory so scan itself works + _make_plan_file(tmp_path, "0001") + + # Make the restricted dir unreadable + os.chmod(str(restricted), 0o000) + try: + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + # Scan should complete without crashing + assert isinstance(result, dict) + assert "total_plans" in result + finally: + os.chmod(str(restricted), 0o755) + + def test_generic_os_error_logs_warning(self, tmp_path, mock_logger): + """Non-PermissionError OSError should be logged as warning.""" + mod = _import_monitor_ops() + # We cannot easily trigger a generic OSError from os.walk, but we + # can directly call the handle_walk_error closure pattern by + # simulating a scan on a non-existent directory. + missing = tmp_path / "nonexistent_root" + with patch.object(mod, "_fire_event", return_value=False): + result = mod.scan_plan_files_impl( + ecosystem_root=missing, + load_registry=lambda: {"plans": {}}, + ) + # Should not crash, just return empty results + assert result["total_plans"] == 0 + assert result["added"] == [] + + +# ═══════════════════════════════════════════════════════════ +# 2. scan_plan_files_impl +# ═══════════════════════════════════════════════════════════ + + +class TestScanPlanFilesImpl: + """Tests for scan_plan_files_impl in monitor_ops.""" + + def test_detects_plan_files_in_root(self, tmp_path): + """Plan files at root level should be detected.""" + mod = _import_monitor_ops() + _make_plan_file(tmp_path, "0001") + _make_plan_file(tmp_path, "0002") + + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + assert "0001" in result["added"] + assert "0002" in result["added"] + assert result["healing_performed"] is True + + def test_detects_plan_files_in_subdirectories(self, tmp_path): + """Plan files in subdirectories should be detected.""" + mod = _import_monitor_ops() + sub = tmp_path / "projects" / "alpha" + sub.mkdir(parents=True) + _make_plan_file(sub, "0010") + + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + assert "0010" in result["added"] + + def test_ignores_non_plan_files(self, tmp_path): + """Non-FPLAN files should be ignored even if they look similar.""" + mod = _import_monitor_ops() + # Valid plan file + _make_plan_file(tmp_path, "0001") + # Invalid files that should not match + (tmp_path / "DPLAN-0002.md").write_text("not a plan", encoding="utf-8") + (tmp_path / "FPLAN-ABC.md").write_text("bad number", encoding="utf-8") + (tmp_path / "README.md").write_text("readme", encoding="utf-8") + + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + assert result["added"] == ["0001"] + + def test_skips_ignored_folders(self, tmp_path): + """Directories in IGNORE_FOLDERS should be skipped.""" + mod = _import_monitor_ops() + # Plan file in ignored directory + git_dir = tmp_path / ".git" + git_dir.mkdir() + _make_plan_file(git_dir, "0001") + + pycache_dir = tmp_path / "__pycache__" + pycache_dir.mkdir() + _make_plan_file(pycache_dir, "0002") + + # Plan file in non-ignored directory + good_dir = tmp_path / "active" + good_dir.mkdir() + _make_plan_file(good_dir, "0003") + + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + assert "0003" in result["added"] + assert "0001" not in result["added"] + assert "0002" not in result["added"] + + def test_detects_orphaned_registry_entries(self, tmp_path): + """Registry entries with no matching file should fire deleted events.""" + mod = _import_monitor_ops() + # No plan files on disk, but registry has entries + registry = { + "plans": { + "0001": {"file_path": str(tmp_path / "FPLAN-0001.md"), "status": "open"}, + "0002": {"file_path": str(tmp_path / "FPLAN-0002.md"), "status": "open"}, + } + } + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: registry, + ) + assert "0001" in result["removed"] + assert "0002" in result["removed"] + assert result["healing_performed"] is True + + def test_detects_moved_files(self, tmp_path): + """Files that exist but at a different path should fire moved events.""" + mod = _import_monitor_ops() + new_dir = tmp_path / "new_location" + new_dir.mkdir() + plan = _make_plan_file(new_dir, "0001") + + registry = { + "plans": { + "0001": { + "file_path": str(tmp_path / "old_location" / "FPLAN-0001.md"), + "status": "open", + }, + } + } + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: registry, + ) + assert "0001" in result["updated"] + + def test_no_changes_needed(self, tmp_path): + """When disk matches registry, no healing should be needed.""" + mod = _import_monitor_ops() + plan = _make_plan_file(tmp_path, "0001") + + registry = { + "plans": { + "0001": {"file_path": str(plan), "status": "open"}, + } + } + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: registry, + ) + assert result["added"] == [] + assert result["updated"] == [] + assert result["removed"] == [] + assert result["renumbered"] == [] + assert result["healing_performed"] is False + + def test_duplicate_plan_files_renumbered(self, tmp_path): + """Duplicate plan numbers should be auto-renumbered.""" + mod = _import_monitor_ops() + # Create two directories with same plan number + dir_a = tmp_path / "project_a" + dir_a.mkdir() + dir_b = tmp_path / "project_b" + dir_b.mkdir() + + _make_plan_file(dir_a, "0001") + _make_plan_file(dir_b, "0001") + + with patch.object(mod, "_fire_event", return_value=True): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + assert len(result["renumbered"]) == 1 + assert result["renumbered"][0]["old_number"] == "0001" + assert result["renumbered"][0]["new_number"] == "0002" + assert result["healing_performed"] is True + + def test_scan_calls_json_handler_log(self, tmp_path, mock_json_handler): + """Scan should log its results via json_handler.""" + mod = _import_monitor_ops() + _make_plan_file(tmp_path, "0001") + + with patch.object(mod, "_fire_event", return_value=True): + mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + mock_json_handler.assert_called_once() + call_args = mock_json_handler.call_args + assert call_args[0][0] == "plan_files_scanned" + assert call_args[0][1]["success"] is True + + def test_fire_event_failure_excludes_from_results(self, tmp_path): + """If _fire_event returns False, the plan should not appear in added.""" + mod = _import_monitor_ops() + _make_plan_file(tmp_path, "0001") + + with patch.object(mod, "_fire_event", return_value=False): + result = mod.scan_plan_files_impl( + ecosystem_root=tmp_path, + load_registry=lambda: {"plans": {}}, + ) + assert result["added"] == [] + + +# ═══════════════════════════════════════════════════════════ +# 3. PlanFileWatcher event handlers +# ═══════════════════════════════════════════════════════════ + + +class TestPlanFileWatcherOnCreated: + """Tests for PlanFileWatcher.on_created.""" + + def setup_method(self): + """Clear deduplication state before each test.""" + mod = _import_monitor_ops() + mod._recent_events.clear() + + def test_created_event_for_plan_file(self, tmp_path): + """on_created should log and schedule fire for a valid FPLAN file.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "FPLAN-0042.md")) + + with patch.object(watcher, "_schedule_fire_created") as mock_fire: + watcher.on_created(event) + mock_fire.assert_called_once() + call_path = mock_fire.call_args[0][0] + assert call_path.name == "FPLAN-0042.md" + + def test_created_event_ignores_directory(self, tmp_path): + """on_created should ignore directory events.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "FPLAN-0042.md"), is_directory=True) + + with patch.object(watcher, "_schedule_fire_created") as mock_fire: + watcher.on_created(event) + mock_fire.assert_not_called() + + def test_created_event_ignores_non_plan_file(self, tmp_path): + """on_created should ignore non-FPLAN files.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "README.md")) + + with patch.object(watcher, "_schedule_fire_created") as mock_fire: + watcher.on_created(event) + mock_fire.assert_not_called() + + def test_created_event_deduplication(self, tmp_path): + """Duplicate create events for the same plan within DEDUPE_WINDOW should be ignored.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "FPLAN-0042.md")) + + with patch.object(watcher, "_schedule_fire_created") as mock_fire: + watcher.on_created(event) + watcher.on_created(event) # duplicate + assert mock_fire.call_count == 1 + + +class TestPlanFileWatcherOnDeleted: + """Tests for PlanFileWatcher.on_deleted.""" + + def setup_method(self): + mod = _import_monitor_ops() + mod._recent_events.clear() + + def test_deleted_event_for_plan_file(self, tmp_path): + """on_deleted should log and schedule fire for a valid FPLAN file.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "FPLAN-0007.md")) + + with patch.object(watcher, "_schedule_fire_deleted") as mock_fire: + watcher.on_deleted(event) + mock_fire.assert_called_once() + call_path = mock_fire.call_args[0][0] + assert call_path.name == "FPLAN-0007.md" + + def test_deleted_event_ignores_directory(self, tmp_path): + """on_deleted should ignore directory events.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "FPLAN-0007.md"), is_directory=True) + + with patch.object(watcher, "_schedule_fire_deleted") as mock_fire: + watcher.on_deleted(event) + mock_fire.assert_not_called() + + def test_deleted_event_ignores_non_plan_file(self, tmp_path): + """on_deleted should ignore non-FPLAN files.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "notes.txt")) + + with patch.object(watcher, "_schedule_fire_deleted") as mock_fire: + watcher.on_deleted(event) + mock_fire.assert_not_called() + + def test_deleted_event_deduplication(self, tmp_path): + """Duplicate delete events within DEDUPE_WINDOW should be ignored.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event(str(tmp_path / "FPLAN-0007.md")) + + with patch.object(watcher, "_schedule_fire_deleted") as mock_fire: + watcher.on_deleted(event) + watcher.on_deleted(event) # duplicate + assert mock_fire.call_count == 1 + + +class TestPlanFileWatcherOnMoved: + """Tests for PlanFileWatcher.on_moved.""" + + def setup_method(self): + mod = _import_monitor_ops() + mod._recent_events.clear() + + def test_moved_event_for_plan_file(self, tmp_path): + """on_moved should log and schedule fire when dest is a valid FPLAN file.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + src = str(tmp_path / "old" / "FPLAN-0003.md") + dest = str(tmp_path / "new" / "FPLAN-0003.md") + event = _make_event(src, dest_path=dest) + + with patch.object(watcher, "_schedule_fire_moved") as mock_fire: + watcher.on_moved(event) + mock_fire.assert_called_once() + call_src = mock_fire.call_args[0][0] + call_dest = mock_fire.call_args[0][1] + assert call_src == Path(src) + assert call_dest == Path(dest) + + def test_moved_event_ignores_directory(self, tmp_path): + """on_moved should ignore directory events.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event( + str(tmp_path / "FPLAN-0003.md"), + dest_path=str(tmp_path / "new" / "FPLAN-0003.md"), + is_directory=True, + ) + + with patch.object(watcher, "_schedule_fire_moved") as mock_fire: + watcher.on_moved(event) + mock_fire.assert_not_called() + + def test_moved_event_ignores_non_plan_dest(self, tmp_path): + """on_moved should ignore moves where dest is not a FPLAN file.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event( + str(tmp_path / "FPLAN-0003.md"), + dest_path=str(tmp_path / "renamed.txt"), + ) + + with patch.object(watcher, "_schedule_fire_moved") as mock_fire: + watcher.on_moved(event) + mock_fire.assert_not_called() + + def test_moved_event_deduplication(self, tmp_path): + """Duplicate move events within DEDUPE_WINDOW should be ignored.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + event = _make_event( + str(tmp_path / "old" / "FPLAN-0003.md"), + dest_path=str(tmp_path / "new" / "FPLAN-0003.md"), + ) + + with patch.object(watcher, "_schedule_fire_moved") as mock_fire: + watcher.on_moved(event) + watcher.on_moved(event) # duplicate + assert mock_fire.call_count == 1 + + +# ═══════════════════════════════════════════════════════════ +# 4. start_monitoring_impl / stop_monitoring_impl / get_status_impl +# ═══════════════════════════════════════════════════════════ + + +class TestStartMonitoringImpl: + """Tests for start_monitoring_impl in monitor_ops.""" + + def teardown_method(self): + """Ensure observer is stopped after each test.""" + mod = _import_monitor_ops() + if mod._observer and mod._observer.is_alive(): + mod._observer.stop() + mod._observer.join() + mod._observer = None + + def test_start_returns_success(self, tmp_path): + """Starting monitor on a valid directory should succeed.""" + mod = _import_monitor_ops() + mod._observer = None + result = mod.start_monitoring_impl(tmp_path) + assert result["success"] is True + assert result["status"] == "started" + assert str(tmp_path) in result["message"] + + def test_start_when_already_running(self, tmp_path): + """Starting monitor when already running should return already_running.""" + mod = _import_monitor_ops() + mod._observer = None + # Start once + mod.start_monitoring_impl(tmp_path) + # Start again + result = mod.start_monitoring_impl(tmp_path) + assert result["success"] is False + assert result["status"] == "already_running" + + def test_start_handles_observer_exception(self, tmp_path): + """If Observer raises, start should return error status.""" + mod = _import_monitor_ops() + mod._observer = None + with patch("aipass.flow.apps.handlers.registry.monitor_ops.Observer") as mock_obs: + mock_obs.return_value.start.side_effect = RuntimeError("Cannot start") + result = mod.start_monitoring_impl(tmp_path) + assert result["success"] is False + assert result["status"] == "error" + assert "Cannot start" in result["message"] + + +class TestStopMonitoringImpl: + """Tests for stop_monitoring_impl in monitor_ops.""" + + def teardown_method(self): + mod = _import_monitor_ops() + mod._observer = None + + def test_stop_running_observer(self, tmp_path): + """Stopping a running observer should succeed.""" + mod = _import_monitor_ops() + mod._observer = None + mod.start_monitoring_impl(tmp_path) + result = mod.stop_monitoring_impl() + assert result["success"] is True + assert result["status"] == "stopped" + + def test_stop_when_not_running(self): + """Stopping when no observer is running should return not_running.""" + mod = _import_monitor_ops() + mod._observer = None + result = mod.stop_monitoring_impl() + assert result["success"] is False + assert result["status"] == "not_running" + + +class TestGetStatusImpl: + """Tests for get_status_impl in monitor_ops.""" + + def teardown_method(self): + mod = _import_monitor_ops() + if mod._observer and mod._observer.is_alive(): + mod._observer.stop() + mod._observer.join() + mod._observer = None + + def test_status_when_not_monitoring(self, tmp_path): + """Status should report inactive when no observer is running.""" + mod = _import_monitor_ops() + mod._observer = None + registry = { + "plans": { + "0001": {"status": "open"}, + "0002": {"status": "closed"}, + "0003": {"status": "open"}, + } + } + result = mod.get_status_impl(tmp_path, load_registry=lambda: registry) + assert not result["monitoring_active"] + assert result["total_plans"] == 3 + assert result["open_plans"] == 2 + assert result["watch_location"] == str(tmp_path) + assert result["module"] == "registry_monitor" + assert result["version"] == "2.0.0" + assert result["ignore_folders"] == len(mod.IGNORE_FOLDERS) + + def test_status_when_monitoring_active(self, tmp_path): + """Status should report active when observer is running.""" + mod = _import_monitor_ops() + mod._observer = None + mod.start_monitoring_impl(tmp_path) + result = mod.get_status_impl(tmp_path, load_registry=lambda: {"plans": {}}) + assert result["monitoring_active"] is True + + def test_status_with_empty_registry(self, tmp_path): + """Status should handle empty registry.""" + mod = _import_monitor_ops() + mod._observer = None + result = mod.get_status_impl(tmp_path, load_registry=lambda: {"plans": {}}) + assert result["total_plans"] == 0 + assert result["open_plans"] == 0 + + +# ═══════════════════════════════════════════════════════════ +# 5. registry_monitor module wrappers +# ═══════════════════════════════════════════════════════════ + + +class TestRegistryMonitorStartMonitoring: + """Tests for registry_monitor.start_monitoring wrapper.""" + + def test_start_monitoring_success(self): + """Successful start should print success message and return True.""" + mod = _import_registry_monitor() + mock_con = MagicMock() + with patch.object(mod, "start_monitoring_impl", + return_value={"success": True, "status": "started", "message": "Monitor started"}, + ), patch.object(mod, "console", mock_con): + result = mod.start_monitoring() + assert result is True + mock_con.print.assert_called() + + def test_start_monitoring_already_running(self): + """Already-running should trigger warning and return False.""" + mod = _import_registry_monitor() + mock_warn = MagicMock() + with patch.object(mod, "start_monitoring_impl", + return_value={"success": False, "status": "already_running", "message": "Already running"}, + ), patch.object(mod, "warning", mock_warn): + result = mod.start_monitoring() + assert result is False + mock_warn.assert_called_once_with("Monitor is already running") + + def test_start_monitoring_error(self): + """Error status should trigger error display and return False.""" + mod = _import_registry_monitor() + mock_err = MagicMock() + with patch.object(mod, "start_monitoring_impl", + return_value={"success": False, "status": "error", "message": "Observer failed"}, + ), patch.object(mod, "error", mock_err): + result = mod.start_monitoring() + assert result is False + mock_err.assert_called_once_with("Observer failed") + + +class TestRegistryMonitorStopMonitoring: + """Tests for registry_monitor.stop_monitoring wrapper.""" + + def test_stop_monitoring_success(self): + """Successful stop should print message and return True.""" + mod = _import_registry_monitor() + mock_con = MagicMock() + with patch.object(mod, "stop_monitoring_impl", + return_value={"success": True, "status": "stopped", "message": "Monitor stopped"}, + ), patch.object(mod, "console", mock_con): + result = mod.stop_monitoring() + assert result is True + mock_con.print.assert_called() + + def test_stop_monitoring_not_running(self): + """Stopping when not running should trigger warning and return False.""" + mod = _import_registry_monitor() + mock_warn = MagicMock() + with patch.object(mod, "stop_monitoring_impl", + return_value={"success": False, "status": "not_running", "message": "Not running"}, + ), patch.object(mod, "warning", mock_warn): + result = mod.stop_monitoring() + assert result is False + mock_warn.assert_called_once_with("Monitor is not running") + + +class TestRegistryMonitorGetStatus: + """Tests for registry_monitor.get_status wrapper.""" + + def test_get_status_delegates_to_impl(self): + """get_status should delegate to get_status_impl with correct args.""" + mod = _import_registry_monitor() + expected = { + "module": "registry_monitor", + "version": "2.0.0", + "monitoring_active": False, + "watch_location": "/some/path", + "total_plans": 5, + "open_plans": 3, + "ignore_folders": 20, + } + with patch.object(mod, "get_status_impl", return_value=expected) as mock_impl: + result = mod.get_status() + assert result == expected + mock_impl.assert_called_once_with( + ecosystem_root=mod.ECOSYSTEM_ROOT, + load_registry=mod.load_registry, + ) + + +# ═══════════════════════════════════════════════════════════ +# 6. _fire_event helper +# ═══════════════════════════════════════════════════════════ + + +class TestFireEvent: + """Tests for the _fire_event helper function.""" + + def test_fire_event_success(self): + """Successful event fire should return True.""" + mod = _import_monitor_ops() + mock_trigger = MagicMock() + fake_core = MagicMock(trigger=mock_trigger) + with patch.dict( + "sys.modules", + {"aipass.trigger.apps.modules.core": fake_core}, + ): + result = mod._fire_event("plan_file_created", path="/test/FPLAN-0001.md") + assert result is True + mock_trigger.fire.assert_called_once_with("plan_file_created", path="/test/FPLAN-0001.md") + + def test_fire_event_import_error(self, mock_logger): + """ImportError should return False and log warning.""" + mod = _import_monitor_ops() + real_import = builtins.__import__ + + def _failing_import( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, + ) -> types.ModuleType: + if name == "aipass.trigger.apps.modules.core": + raise ImportError("trigger not installed") + return real_import(name, globals, locals, fromlist, level) + + with patch.object(builtins, "__import__", side_effect=_failing_import): + result = mod._fire_event("test_event") + assert result is False + + +# ═══════════════════════════════════════════════════════════ +# 7. PlanFileWatcher internal methods +# ═══════════════════════════════════════════════════════════ + + +class TestPlanFileWatcherInternals: + """Tests for PlanFileWatcher helper methods.""" + + def test_is_plan_file_valid(self): + """Valid FPLAN filenames should return True.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + assert watcher._is_plan_file("/some/path/FPLAN-0001.md") is True + assert watcher._is_plan_file("/some/path/FPLAN-9999.md") is True + + def test_is_plan_file_invalid(self): + """Invalid filenames should return False.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + assert watcher._is_plan_file("/some/path/DPLAN-0001.md") is False + assert watcher._is_plan_file("/some/path/FPLAN-ABC.md") is False + assert watcher._is_plan_file("/some/path/FPLAN-00001.md") is False + assert watcher._is_plan_file("/some/path/README.md") is False + assert watcher._is_plan_file("/some/path/FPLAN-0001.txt") is False + + def test_get_plan_number(self): + """Should extract the 4-digit number from FPLAN filename.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + assert watcher._get_plan_number(Path("FPLAN-0042.md")) == "0042" + assert watcher._get_plan_number(Path("FPLAN-0001.md")) == "0001" + assert watcher._get_plan_number(Path("/deep/path/FPLAN-1234.md")) == "1234" + + def test_get_plan_number_invalid(self): + """Invalid filenames should return None.""" + mod = _import_monitor_ops() + watcher = mod.PlanFileWatcher() + assert watcher._get_plan_number(Path("README.md")) is None + assert watcher._get_plan_number(Path("FPLAN-ABC.md")) is None + + def test_deduplication_window_expires(self): + """Events outside DEDUPE_WINDOW should not be considered duplicates.""" + mod = _import_monitor_ops() + mod._recent_events.clear() + watcher = mod.PlanFileWatcher() + + # Add an event with an old timestamp + mod._recent_events.append(("created", "0001", time.time() - 10.0)) + + # Should not be duplicate since old event is beyond DEDUPE_WINDOW + assert watcher._is_duplicate_event("created", "0001") is False + + def test_different_event_types_not_deduplicated(self): + """Different event types for same plan should not be deduplicated.""" + mod = _import_monitor_ops() + mod._recent_events.clear() + watcher = mod.PlanFileWatcher() + + assert watcher._is_duplicate_event("created", "0001") is False + assert watcher._is_duplicate_event("deleted", "0001") is False # different type diff --git a/src/aipass/flow/tests/test_plan_handlers.py b/src/aipass/flow/tests/test_plan_handlers.py new file mode 100644 index 00000000..dba304d3 --- /dev/null +++ b/src/aipass/flow/tests/test_plan_handlers.py @@ -0,0 +1,761 @@ +"""Tests for plan handler functions in aipass.flow.apps.handlers.plan.* + +Covers: slugify_subject, create_plan_impl, create_plan_file, + build_plan_registry_entry, calculate_relative_location, + resolve_plan_location, auto_close_orphaned_plans, get_closed_plans, + update_data_metrics (json_handler). +""" + +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Module imports +# --------------------------------------------------------------------------- +from aipass.flow.apps.handlers.plan.create_ops import slugify_subject, create_plan_impl +from aipass.flow.apps.handlers.plan.create_file import create_plan_file +from aipass.flow.apps.handlers.plan.build_registry_entry import build_plan_registry_entry +from aipass.flow.apps.handlers.plan.calculate_relative_path import calculate_relative_location +from aipass.flow.apps.handlers.plan.resolve_location import resolve_plan_location +from aipass.flow.apps.handlers.plan.auto_cleanup import auto_close_orphaned_plans +from aipass.flow.apps.handlers.plan.get_closed_plans import get_closed_plans +from aipass.flow.apps.handlers.json.json_handler import update_data_metrics + + +# ========================================================================= +# slugify_subject +# ========================================================================= + +class TestSlugifySubject: + """Tests for slugify_subject().""" + + def test_basic_lowercase_and_underscores(self): + result = slugify_subject("Hello World") + assert result == "hello_world" + + def test_strips_special_characters(self): + result = slugify_subject("Fix: bugs & issues!") + assert result == "fix_bugs_issues" + + def test_collapses_multiple_spaces_and_dashes(self): + result = slugify_subject("too many---dashes") + assert result == "too_many_dashes" + + def test_respects_max_length_default(self): + long_subject = "a" * 60 + result = slugify_subject(long_subject) + assert len(result) <= 40 + + def test_respects_custom_max_length(self): + result = slugify_subject("a" * 60, max_length=10) + assert len(result) == 10 + + def test_strips_leading_trailing_underscores(self): + result = slugify_subject(" -hello- ") + assert result == "hello" + + def test_empty_string(self): + result = slugify_subject("") + assert result == "" + + def test_only_special_characters(self): + result = slugify_subject("!@#$%^&*()") + assert result == "" + + def test_preserves_digits(self): + result = slugify_subject("Plan 42 rollout") + assert result == "plan_42_rollout" + + +# ========================================================================= +# create_plan_file +# ========================================================================= + +class TestCreatePlanFile: + """Tests for create_plan_file().""" + + def test_creates_file_successfully(self, tmp_path: Path): + plan_file = tmp_path / "FPLAN-0001_test_2026-01-01.md" + content = "# FPLAN-0001\n\nTest content" + success, error = create_plan_file(plan_file, content) + + assert success is True + assert error == "" + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == content + + def test_fails_when_file_already_exists(self, tmp_path: Path): + plan_file = tmp_path / "FPLAN-0001_test.md" + plan_file.write_text("existing", encoding="utf-8") + + success, error = create_plan_file(plan_file, "new content") + + assert success is False + assert "already exists" in error + assert plan_file.read_text(encoding="utf-8") == "existing" + + def test_fails_on_unwritable_directory(self, tmp_path: Path): + bad_path = tmp_path / "nonexistent_dir" / "FPLAN-0001.md" + success, error = create_plan_file(bad_path, "content") + + assert success is False + assert "Failed to create" in error + + def test_error_message_includes_parent_name(self, tmp_path: Path): + plan_file = tmp_path / "FPLAN-0001.md" + plan_file.write_text("x", encoding="utf-8") + + _, error = create_plan_file(plan_file, "y") + assert tmp_path.name in error + + +# ========================================================================= +# build_plan_registry_entry +# ========================================================================= + +class TestBuildPlanRegistryEntry: + """Tests for build_plan_registry_entry().""" + + def test_returns_correct_structure(self, tmp_path: Path): + plan_file = tmp_path / "FPLAN-0005.md" + entry = build_plan_registry_entry( + plan_num=5, + target_dir=tmp_path, + relative_location="flow", + subject="Deploy widget", + plan_file=plan_file, + template_type="default", + ) + + assert entry["location"] == str(tmp_path) + assert entry["relative_path"] == "flow" + assert entry["subject"] == "Deploy widget" + assert entry["status"] == "open" + assert entry["file_path"] == str(plan_file) + assert entry["template_type"] == "default" + + def test_created_timestamp_is_iso_utc(self, tmp_path: Path): + before = datetime.now(timezone.utc) + entry = build_plan_registry_entry( + plan_num=1, + target_dir=tmp_path, + relative_location="root", + subject="test", + plan_file=tmp_path / "FPLAN-0001.md", + template_type="default", + ) + after = datetime.now(timezone.utc) + + created = datetime.fromisoformat(entry["created"]) + assert before <= created <= after + + def test_all_required_keys_present(self, tmp_path: Path): + entry = build_plan_registry_entry( + plan_num=1, + target_dir=tmp_path, + relative_location="root", + subject="anything", + plan_file=tmp_path / "p.md", + template_type="master", + ) + required_keys = {"location", "relative_path", "created", "subject", "status", "file_path", "template_type"} + assert required_keys == set(entry.keys()) + + +# ========================================================================= +# calculate_relative_location +# ========================================================================= + +class TestCalculateRelativeLocation: + """Tests for calculate_relative_location().""" + + def test_subdirectory_returns_relative(self, tmp_path: Path): + root = tmp_path / "repo" + target = root / "src" / "flow" + root.mkdir() + target.mkdir(parents=True) + + result = calculate_relative_location(target, root) + assert result == "src/flow" + + def test_same_directory_returns_root(self, tmp_path: Path): + result = calculate_relative_location(tmp_path, tmp_path) + assert result == "root" + + def test_outside_ecosystem_returns_absolute(self, tmp_path: Path): + root = tmp_path / "repo" + outside = tmp_path / "other" + root.mkdir() + outside.mkdir() + + result = calculate_relative_location(outside, root) + assert result == str(outside) + + def test_deeply_nested_path(self, tmp_path: Path): + root = tmp_path + target = tmp_path / "a" / "b" / "c" / "d" + target.mkdir(parents=True) + + result = calculate_relative_location(target, root) + assert result == "a/b/c/d" + + +# ========================================================================= +# resolve_plan_location +# ========================================================================= + +class TestResolvePlanLocation: + """Tests for resolve_plan_location().""" + + def test_none_location_uses_caller_cwd(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.plan.resolve_location._get_caller_cwd", + return_value=tmp_path, + ): + success, resolved, error = resolve_plan_location(None, tmp_path) + + assert success is True + assert resolved == tmp_path + assert error == "" + + def test_absolute_path_resolves_directly(self, tmp_path: Path): + target = tmp_path / "plans" + target.mkdir() + + with patch( + "aipass.flow.apps.handlers.plan.resolve_location._get_caller_cwd", + return_value=tmp_path, + ): + success, resolved, error = resolve_plan_location(str(target), tmp_path) + + assert success is True + assert resolved == target.resolve() + assert error == "" + + def test_relative_path_resolves_against_caller_cwd(self, tmp_path: Path): + subdir = tmp_path / "subdir" + subdir.mkdir() + + with patch( + "aipass.flow.apps.handlers.plan.resolve_location._get_caller_cwd", + return_value=tmp_path, + ): + success, resolved, error = resolve_plan_location("subdir", tmp_path) + + assert success is True + assert resolved == subdir.resolve() + + def test_nonexistent_directory_returns_failure(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.plan.resolve_location._get_caller_cwd", + return_value=tmp_path, + ): + success, _, error = resolve_plan_location("/no/such/place", tmp_path) + + assert success is False + assert "does not exist" in error + + def test_dot_resolves_to_caller_cwd(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.plan.resolve_location._get_caller_cwd", + return_value=tmp_path, + ): + success, resolved, error = resolve_plan_location(".", tmp_path) + + assert success is True + assert resolved == tmp_path.resolve() + + +# ========================================================================= +# auto_close_orphaned_plans +# ========================================================================= + +class TestAutoCloseOrphanedPlans: + """Tests for auto_close_orphaned_plans().""" + + def test_closes_plans_with_missing_files(self, tmp_path: Path): + registry = { + "plans": { + "1": { + "status": "open", + "file_path": str(tmp_path / "does_not_exist.md"), + }, + } + } + updated, count = auto_close_orphaned_plans(registry) + + assert count == 1 + assert updated["plans"]["1"]["status"] == "closed" + assert updated["plans"]["1"]["closed_reason"] == "auto_closed_missing_file" + assert "closed" in updated["plans"]["1"] + + def test_leaves_existing_open_plans_alone(self, tmp_path: Path): + plan_file = tmp_path / "FPLAN-0001.md" + plan_file.write_text("content", encoding="utf-8") + + registry = { + "plans": { + "1": { + "status": "open", + "file_path": str(plan_file), + }, + } + } + updated, count = auto_close_orphaned_plans(registry) + + assert count == 0 + assert updated["plans"]["1"]["status"] == "open" + + def test_ignores_already_closed_plans(self, tmp_path: Path): + registry = { + "plans": { + "1": { + "status": "closed", + "file_path": str(tmp_path / "gone.md"), + }, + } + } + updated, count = auto_close_orphaned_plans(registry) + + assert count == 0 + assert updated["plans"]["1"]["status"] == "closed" + + def test_handles_empty_registry(self): + registry = {"plans": {}} + updated, count = auto_close_orphaned_plans(registry) + + assert count == 0 + assert updated["plans"] == {} + + def test_handles_missing_plans_key(self): + registry = {} + updated, count = auto_close_orphaned_plans(registry) + + assert count == 0 + + def test_multiple_orphaned_plans(self, tmp_path: Path): + registry = { + "plans": { + "1": {"status": "open", "file_path": str(tmp_path / "a.md")}, + "2": {"status": "open", "file_path": str(tmp_path / "b.md")}, + "3": {"status": "open", "file_path": str(tmp_path / "c.md")}, + } + } + updated, count = auto_close_orphaned_plans(registry) + + assert count == 3 + for num in ("1", "2", "3"): + assert updated["plans"][num]["status"] == "closed" + + def test_mixed_existing_and_orphaned(self, tmp_path: Path): + existing = tmp_path / "exists.md" + existing.write_text("hi", encoding="utf-8") + + registry = { + "plans": { + "1": {"status": "open", "file_path": str(existing)}, + "2": {"status": "open", "file_path": str(tmp_path / "gone.md")}, + "3": {"status": "closed", "file_path": str(tmp_path / "also_gone.md")}, + } + } + updated, count = auto_close_orphaned_plans(registry) + + assert count == 1 + assert updated["plans"]["1"]["status"] == "open" + assert updated["plans"]["2"]["status"] == "closed" + assert updated["plans"]["3"]["status"] == "closed" + + def test_closed_timestamp_is_valid_iso(self, tmp_path: Path): + registry = { + "plans": { + "1": { + "status": "open", + "file_path": str(tmp_path / "nope.md"), + }, + } + } + before = datetime.now(timezone.utc) + updated, _ = auto_close_orphaned_plans(registry) + after = datetime.now(timezone.utc) + + ts = datetime.fromisoformat(updated["plans"]["1"]["closed"]) + assert before <= ts <= after + + +# ========================================================================= +# get_closed_plans +# ========================================================================= + +class TestGetClosedPlans: + """Tests for get_closed_plans().""" + + def test_returns_only_closed_plans(self, mock_registry): + _, registry = mock_registry + with patch( + "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry", + return_value=registry, + ): + result = get_closed_plans() + + assert len(result) == 1 + plan_num, plan_info = result[0] + assert plan_num == "2" + assert plan_info["status"] == "closed" + + def test_returns_empty_when_no_closed(self): + registry = { + "plans": { + "1": {"status": "open", "subject": "active"}, + } + } + with patch( + "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry", + return_value=registry, + ): + result = get_closed_plans() + + assert result == [] + + def test_returns_empty_on_empty_registry(self): + with patch( + "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry", + return_value={"plans": {}}, + ): + result = get_closed_plans() + + assert result == [] + + def test_returns_multiple_closed_plans(self): + registry = { + "plans": { + "1": {"status": "closed", "subject": "done A"}, + "2": {"status": "closed", "subject": "done B"}, + "3": {"status": "open", "subject": "still going"}, + } + } + with patch( + "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry", + return_value=registry, + ): + result = get_closed_plans() + + assert len(result) == 2 + subjects = {info["subject"] for _, info in result} + assert subjects == {"done A", "done B"} + + def test_result_tuples_contain_plan_num_and_info(self, mock_registry): + _, registry = mock_registry + with patch( + "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry", + return_value=registry, + ): + result = get_closed_plans() + + for plan_num, plan_info in result: + assert isinstance(plan_num, str) + assert isinstance(plan_info, dict) + assert "subject" in plan_info + + +# ========================================================================= +# update_data_metrics (json_handler) +# ========================================================================= + +class TestUpdateDataMetrics: + """Tests for update_data_metrics() in json_handler.""" + + def test_updates_single_metric(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", + tmp_path, + ): + # Seed the data file with the minimum required structure + data_file = tmp_path / "testmod_data.json" + data_file.write_text( + json.dumps({"created": "2026-01-01", "last_updated": "2026-01-01"}), + encoding="utf-8", + ) + + result = update_data_metrics("testmod", total_plans=42) + + assert result is True + saved = json.loads(data_file.read_text(encoding="utf-8")) + assert saved["total_plans"] == 42 + + def test_updates_multiple_metrics(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", + tmp_path, + ): + data_file = tmp_path / "testmod_data.json" + data_file.write_text( + json.dumps({"created": "2026-01-01", "last_updated": "2026-01-01"}), + encoding="utf-8", + ) + + result = update_data_metrics("testmod", open=5, closed=3, total=8) + + assert result is True + saved = json.loads(data_file.read_text(encoding="utf-8")) + assert saved["open"] == 5 + assert saved["closed"] == 3 + assert saved["total"] == 8 + + def test_returns_false_when_data_load_fails(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", + tmp_path / "nonexistent", + ), patch( + "aipass.flow.apps.handlers.json.json_handler.load_json", + return_value=None, + ): + result = update_data_metrics("broken_mod", x=1) + + assert result is False + + def test_overwrites_existing_metric(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", + tmp_path, + ): + data_file = tmp_path / "testmod_data.json" + data_file.write_text( + json.dumps({ + "created": "2026-01-01", + "last_updated": "2026-01-01", + "counter": 10, + }), + encoding="utf-8", + ) + + update_data_metrics("testmod", counter=20) + + saved = json.loads(data_file.read_text(encoding="utf-8")) + assert saved["counter"] == 20 + + def test_updates_last_updated_field(self, tmp_path: Path): + with patch( + "aipass.flow.apps.handlers.json.json_handler.FLOW_JSON_DIR", + tmp_path, + ): + data_file = tmp_path / "testmod_data.json" + data_file.write_text( + json.dumps({"created": "2026-01-01", "last_updated": "2020-01-01"}), + encoding="utf-8", + ) + + update_data_metrics("testmod", score=99) + + saved = json.loads(data_file.read_text(encoding="utf-8")) + assert saved["last_updated"] != "2020-01-01" + + +# ========================================================================= +# create_plan_impl +# ========================================================================= + +class TestCreatePlanImpl: + """Tests for create_plan_impl().""" + + def _make_deps(self, **overrides) -> dict: + """Build a complete set of MagicMock dependencies for create_plan_impl.""" + registry = {"next_number": 1, "plans": {}} + deps = { + "load_registry": MagicMock(return_value=registry), + "save_registry": MagicMock(return_value=True), + "auto_close_orphaned_plans": MagicMock(return_value=(registry, 0)), + "resolve_plan_location": MagicMock( + return_value=(True, Path("/tmp/plans"), ""), + ), + "calculate_relative_location": MagicMock(return_value="plans"), + "get_template": MagicMock(return_value="# Plan content"), + "create_plan_file": MagicMock(return_value=(True, "")), + "build_plan_registry_entry": MagicMock(return_value={"status": "open"}), + "display_plan_created": MagicMock(return_value="Plan created"), + "update_dashboard_local": MagicMock(return_value=True), + "push_to_plans_central": MagicMock(return_value=True), + "push_flow_to_branch_dashboard": MagicMock(return_value=True), + } + deps.update(overrides) + return deps + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_successful_creation(self, mock_log, mock_jh): + deps = self._make_deps() + success, plan_num, loc, tmpl, err, msgs = create_plan_impl( + location="/tmp/plans", + subject="Widget feature", + template_type="default", + **deps, + ) + + assert success is True + assert plan_num == 1 + assert loc == "plans" + assert err == "" + deps["create_plan_file"].assert_called_once() + deps["save_registry"].assert_called() + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_missing_dependency_returns_failure(self, mock_log, mock_jh): + deps = self._make_deps() + deps["get_template"] = None # Missing dep + + success, plan_num, loc, tmpl, err, msgs = create_plan_impl( + subject="anything", **deps + ) + + assert success is False + assert "Missing required dependency" in err + assert "get_template" in err + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_location_resolution_failure(self, mock_log, mock_jh): + deps = self._make_deps( + resolve_plan_location=MagicMock( + return_value=(False, Path("/tmp"), "Dir not found"), + ), + ) + + success, _, _, _, err, _ = create_plan_impl( + location="/bad/path", subject="test", **deps + ) + + assert success is False + assert err == "Dir not found" + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_file_creation_failure(self, mock_log, mock_jh): + deps = self._make_deps( + create_plan_file=MagicMock(return_value=(False, "File exists")), + ) + + success, _, _, _, err, _ = create_plan_impl( + subject="test", **deps + ) + + assert success is False + assert err == "File exists" + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_auto_cleanup_runs_and_saves(self, mock_log, mock_jh): + registry = {"next_number": 5, "plans": {}} + cleaned_registry = {"next_number": 5, "plans": {}} + deps = self._make_deps( + load_registry=MagicMock(return_value=registry), + auto_close_orphaned_plans=MagicMock( + return_value=(cleaned_registry, 2), + ), + ) + + success, plan_num, _, _, _, msgs = create_plan_impl( + subject="test", **deps + ) + + assert success is True + assert plan_num == 5 + # Auto-cleanup save + registry save after plan creation = 2 calls + assert deps["save_registry"].call_count >= 2 + dim_msgs = [m for m in msgs if m.get("type") == "dim" and "AUTO-CLEANUP" in m.get("text", "")] + assert len(dim_msgs) == 1 + assert "2" in dim_msgs[0]["text"] + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_plan_type_config_used(self, mock_log, mock_jh): + """Plan type config controls prefix, digits, slug length.""" + deps = self._make_deps() + config = { + "prefix": "DPLAN", + "digits": 3, + "slug_max_length": 20, + } + + success, plan_num, _, _, _, msgs = create_plan_impl( + subject="Testing custom config", + plan_type_config=config, + **deps, + ) + + assert success is True + # Verify the file path used DPLAN prefix with 3-digit formatting + call_args = deps["create_plan_file"].call_args + plan_file_path: Path = call_args[0][0] + assert plan_file_path.name.startswith("DPLAN-001") + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_template_exception_returns_failure(self, mock_log, mock_jh): + deps = self._make_deps( + get_template=MagicMock(side_effect=ValueError("bad template")), + ) + + success, _, _, _, err, _ = create_plan_impl( + subject="test", **deps + ) + + assert success is False + assert "Failed to load template" in err + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_dashboard_failure_does_not_block_success(self, mock_log, mock_jh): + deps = self._make_deps( + update_dashboard_local=MagicMock(return_value=False), + push_to_plans_central=MagicMock(return_value=False), + push_flow_to_branch_dashboard=MagicMock(return_value=False), + ) + + success, _, _, _, err, msgs = create_plan_impl( + subject="test", **deps + ) + + assert success is True + assert err == "" + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_empty_subject_produces_filename_without_slug(self, mock_log, mock_jh): + deps = self._make_deps() + + success, _, _, _, _, _ = create_plan_impl( + subject="", **deps + ) + + assert success is True + call_args = deps["create_plan_file"].call_args + plan_file_path: Path = call_args[0][0] + # With empty subject, filename should be PREFIX-NNNN_date.md (no slug segment) + name = plan_file_path.name + assert name.startswith("FPLAN-0001_") + # Should not have double underscores from empty slug + assert "__" not in name + + @patch("aipass.flow.apps.handlers.plan.create_ops.json_handler") + @patch("aipass.flow.apps.handlers.plan.create_ops.logger") + def test_registry_save_failure_warns_but_returns_success(self, mock_log, mock_jh): + """Plan is created even if registry save fails (file already on disk).""" + save_mock = MagicMock(side_effect=[True, False]) # First for auto-close, second for plan + deps = self._make_deps( + auto_close_orphaned_plans=MagicMock( + return_value=({"next_number": 1, "plans": {}}, 1), + ), + save_registry=save_mock, + ) + + success, _, _, _, _, msgs = create_plan_impl( + subject="test", **deps + ) + + assert success is True + warning_msgs = [m for m in msgs if m.get("type") == "warning"] + assert len(warning_msgs) >= 1 diff --git a/src/aipass/memory/tests/test_archive.py b/src/aipass/memory/tests/test_archive.py new file mode 100644 index 00000000..2cf37d75 --- /dev/null +++ b/src/aipass/memory/tests/test_archive.py @@ -0,0 +1,556 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_archive.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for the archive indexer handler. + +Covers: + - archive/indexer.py (extract_file_info, get_archive_files, load_index, + save_index, build_index, check_for_new_files, get_index_status) + +All tests use mocks/tmp_path -- no live filesystem or infrastructure access. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Import helper +# --------------------------------------------------------------------------- + +def _import_indexer(monkeypatch, tmp_path): + """Import indexer with mocked dependencies and paths pointed at tmp_path.""" + sys.modules.pop("aipass.memory.apps.handlers.archive.indexer", None) + parent = sys.modules.get("aipass.memory.apps.handlers.archive") + if parent is not None and hasattr(parent, "indexer"): + delattr(parent, "indexer") + + from aipass.memory.apps.handlers.archive import indexer + + # Redirect constants to tmp_path + code_archive = tmp_path / "code_archive" + index_path = code_archive / "index.json" + monkeypatch.setattr(indexer, "CODE_ARCHIVE_PATH", code_archive) + monkeypatch.setattr(indexer, "INDEX_PATH", index_path) + + return indexer + + +# =========================================================================== +# Tests: extract_file_info +# =========================================================================== + +class TestExtractFileInfo: + """Test extract_file_info metadata extraction.""" + + def test_valid_python_file(self, monkeypatch, tmp_path): + """Extract info from a well-formed Python file.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + py_file = archive_dir / "example.py" + py_file.write_text( + '"""Module docstring."""\n' + "\n" + "def hello():\n" + ' """Say hello."""\n' + " pass\n" + "\n" + "class Greeter:\n" + ' """A greeter class."""\n' + " pass\n", + encoding="utf-8", + ) + + info = indexer.extract_file_info(py_file) + + assert info["filename"] == "example.py" + assert info["docstring"] == "Module docstring." + assert "hello" in info["functions"] + assert "Greeter" in info["classes"] + assert info["size"] == py_file.stat().st_size + assert info["lines"] == 9 + assert "indexed_at" in info + assert info["path"] == "example.py" + + def test_syntax_error_file(self, monkeypatch, tmp_path): + """A file with invalid syntax should return error dict, not raise.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + bad_file = archive_dir / "broken.py" + bad_file.write_text("def broken(\n # missing close paren", encoding="utf-8") + + info = indexer.extract_file_info(bad_file) + + assert info["filename"] == "broken.py" + assert "error" in info + assert "Syntax error" in info["error"] + assert info["docstring"] is None + assert "functions" not in info + assert "classes" not in info + + def test_unreadable_file(self, monkeypatch, tmp_path): + """A file that cannot be read should return an error dict.""" + indexer = _import_indexer(monkeypatch, tmp_path) + + missing_file = tmp_path / "code_archive" / "nonexistent.py" + + info = indexer.extract_file_info(missing_file) + + assert info["filename"] == "nonexistent.py" + assert "error" in info + assert "indexed_at" in info + + def test_file_no_docstring(self, monkeypatch, tmp_path): + """A file with no module docstring should return None for docstring.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + py_file = archive_dir / "nodoc.py" + py_file.write_text("x = 1\n", encoding="utf-8") + + info = indexer.extract_file_info(py_file) + + assert info["docstring"] is None + assert info["functions"] == [] + assert info["classes"] == [] + + def test_long_docstring_truncated(self, monkeypatch, tmp_path): + """A docstring longer than 200 chars should be truncated with '...'.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + long_doc = "A" * 300 + py_file = archive_dir / "longdoc.py" + py_file.write_text(f'"""{long_doc}"""\n', encoding="utf-8") + + info = indexer.extract_file_info(py_file) + + assert info["docstring"].endswith("...") + assert len(info["docstring"]) == 203 # 200 + "..." + + def test_relative_path_in_subdirectory(self, monkeypatch, tmp_path): + """File in subdirectory should have relative path from CODE_ARCHIVE_PATH.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + sub_dir = archive_dir / "utils" + sub_dir.mkdir(parents=True) + + py_file = sub_dir / "helper.py" + py_file.write_text("pass\n", encoding="utf-8") + + info = indexer.extract_file_info(py_file) + + assert info["path"] == str(Path("utils") / "helper.py") + + +# =========================================================================== +# Tests: get_archive_files +# =========================================================================== + +class TestGetArchiveFiles: + """Test get_archive_files directory scanning.""" + + def test_empty_directory(self, monkeypatch, tmp_path): + """An empty archive directory should return [].""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + result = indexer.get_archive_files() + + assert result == [] + + def test_returns_py_files_sorted(self, monkeypatch, tmp_path): + """Should return .py files sorted by path.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + (archive_dir / "beta.py").write_text("pass\n", encoding="utf-8") + (archive_dir / "alpha.py").write_text("pass\n", encoding="utf-8") + + result = indexer.get_archive_files() + + assert len(result) == 2 + assert result[0].name == "alpha.py" + assert result[1].name == "beta.py" + + def test_excludes_init_py(self, monkeypatch, tmp_path): + """__init__.py files should be excluded.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + (archive_dir / "__init__.py").write_text("", encoding="utf-8") + (archive_dir / "real.py").write_text("pass\n", encoding="utf-8") + + result = indexer.get_archive_files() + + assert len(result) == 1 + assert result[0].name == "real.py" + + def test_directory_does_not_exist(self, monkeypatch, tmp_path): + """If CODE_ARCHIVE_PATH does not exist, return [].""" + indexer = _import_indexer(monkeypatch, tmp_path) + # Do NOT create the directory + + result = indexer.get_archive_files() + + assert result == [] + + def test_includes_files_in_subdirectories(self, monkeypatch, tmp_path): + """rglob should find .py files in subdirectories.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + sub_dir = archive_dir / "subpkg" + sub_dir.mkdir(parents=True) + + (sub_dir / "nested.py").write_text("pass\n", encoding="utf-8") + (archive_dir / "top.py").write_text("pass\n", encoding="utf-8") + + result = indexer.get_archive_files() + + names = [f.name for f in result] + assert "nested.py" in names + assert "top.py" in names + + +# =========================================================================== +# Tests: load_index +# =========================================================================== + +class TestLoadIndex: + """Test load_index file reading.""" + + def test_file_exists(self, monkeypatch, tmp_path): + """Should load existing index.json contents.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + index_data = { + "metadata": {"name": "Test Index", "last_updated": "2026-01-01", "total_files": 1}, + "categories": {"utils": ["helper.py"]}, + "files": {"helper.py": {"filename": "helper.py"}}, + } + (archive_dir / "index.json").write_text( + json.dumps(index_data), encoding="utf-8" + ) + + result = indexer.load_index() + + assert result["metadata"]["name"] == "Test Index" + assert result["files"]["helper.py"]["filename"] == "helper.py" + + def test_file_does_not_exist(self, monkeypatch, tmp_path): + """Should return empty structure with metadata/categories/files keys.""" + indexer = _import_indexer(monkeypatch, tmp_path) + + result = indexer.load_index() + + assert "metadata" in result + assert "categories" in result + assert "files" in result + assert result["metadata"]["total_files"] == 0 + assert result["categories"] == {} + assert result["files"] == {} + + def test_corrupted_file_returns_default(self, monkeypatch, tmp_path): + """A corrupted index.json should return the default structure.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + (archive_dir / "index.json").write_text("not valid json{{{", encoding="utf-8") + + result = indexer.load_index() + + assert "metadata" in result + assert result["files"] == {} + + +# =========================================================================== +# Tests: save_index +# =========================================================================== + +class TestSaveIndex: + """Test save_index file writing.""" + + def test_success(self, monkeypatch, tmp_path): + """Should write index.json and update metadata fields.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + index = { + "metadata": {"name": "Test", "last_updated": None, "total_files": 0}, + "categories": {}, + "files": {"a.py": {"filename": "a.py"}, "b.py": {"filename": "b.py"}}, + } + + result = indexer.save_index(index) + + assert result["success"] is True + + saved = json.loads((archive_dir / "index.json").read_text(encoding="utf-8")) + assert saved["metadata"]["total_files"] == 2 + assert saved["metadata"]["last_updated"] is not None + + def test_failure_returns_error(self, monkeypatch, tmp_path): + """If writing fails, return success=False with error.""" + indexer = _import_indexer(monkeypatch, tmp_path) + # Point INDEX_PATH to an impossible location + monkeypatch.setattr(indexer, "INDEX_PATH", Path("/nonexistent/dir/index.json")) + + index = { + "metadata": {"last_updated": None, "total_files": 0}, + "categories": {}, + "files": {}, + } + + result = indexer.save_index(index) + + assert result["success"] is False + assert "error" in result + + +# =========================================================================== +# Tests: build_index +# =========================================================================== + +class TestBuildIndex: + """Test build_index full scan.""" + + def test_with_files(self, monkeypatch, tmp_path): + """Should index all .py files and return stats.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + sub_dir = archive_dir / "utils" + sub_dir.mkdir(parents=True) + + (archive_dir / "main.py").write_text('"""Main module."""\npass\n', encoding="utf-8") + (sub_dir / "helper.py").write_text('"""Helper."""\npass\n', encoding="utf-8") + + result = indexer.build_index() + + assert result["success"] is True + assert result["files_indexed"] == 2 + assert "utils" in result["categories"] + + # Verify index.json was written + saved = json.loads((archive_dir / "index.json").read_text(encoding="utf-8")) + assert saved["metadata"]["total_files"] == 2 + + def test_with_no_files(self, monkeypatch, tmp_path): + """Should return success with 0 files when dir is empty or missing.""" + indexer = _import_indexer(monkeypatch, tmp_path) + # Do not create code_archive directory + + result = indexer.build_index() + + assert result["success"] is True + assert result["files_indexed"] == 0 + + def test_files_at_root_have_no_category(self, monkeypatch, tmp_path): + """Files directly in code_archive should not create a category.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + (archive_dir / "standalone.py").write_text("pass\n", encoding="utf-8") + + result = indexer.build_index() + + assert result["success"] is True + assert result["files_indexed"] == 1 + assert result["categories"] == [] + + +# =========================================================================== +# Tests: check_for_new_files +# =========================================================================== + +class TestCheckForNewFiles: + """Test check_for_new_files sync logic.""" + + def test_new_files_added(self, monkeypatch, tmp_path): + """New files on disk should be indexed.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + # Save an empty index first + empty_index = { + "metadata": {"last_updated": None, "total_files": 0}, + "categories": {}, + "files": {}, + } + (archive_dir / "index.json").write_text( + json.dumps(empty_index), encoding="utf-8" + ) + + # Now create a file on disk + (archive_dir / "new_file.py").write_text("pass\n", encoding="utf-8") + + result = indexer.check_for_new_files() + + assert result["success"] is True + assert result["new_files"] == 1 + assert result["deleted_files"] == 0 + assert result["action"] == "synced" + + def test_files_deleted(self, monkeypatch, tmp_path): + """Deleted files should be removed from index.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + # Create index with a file that does not exist on disk + index_with_ghost = { + "metadata": {"last_updated": None, "total_files": 1}, + "categories": {}, + "files": { + "ghost.py": {"filename": "ghost.py", "path": "ghost.py"}, + }, + } + (archive_dir / "index.json").write_text( + json.dumps(index_with_ghost), encoding="utf-8" + ) + + result = indexer.check_for_new_files() + + assert result["success"] is True + assert result["deleted_files"] == 1 + assert result["new_files"] == 0 + assert result["action"] == "synced" + + def test_no_changes(self, monkeypatch, tmp_path): + """When index matches disk, no sync action needed.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + # Create a file and its index entry + (archive_dir / "existing.py").write_text("pass\n", encoding="utf-8") + index = { + "metadata": {"last_updated": None, "total_files": 1}, + "categories": {}, + "files": { + "existing.py": {"filename": "existing.py", "path": "existing.py"}, + }, + } + (archive_dir / "index.json").write_text( + json.dumps(index), encoding="utf-8" + ) + + result = indexer.check_for_new_files() + + assert result["success"] is True + assert result["new_files"] == 0 + assert result["deleted_files"] == 0 + assert result["action"] == "none" + + def test_simultaneous_add_and_delete(self, monkeypatch, tmp_path): + """Should handle both new and deleted files in one sync.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + # Index has old_file.py but not new_file.py + index = { + "metadata": {"last_updated": None, "total_files": 1}, + "categories": {}, + "files": { + "old_file.py": {"filename": "old_file.py", "path": "old_file.py"}, + }, + } + (archive_dir / "index.json").write_text( + json.dumps(index), encoding="utf-8" + ) + + # Disk has new_file.py but not old_file.py + (archive_dir / "new_file.py").write_text("pass\n", encoding="utf-8") + + result = indexer.check_for_new_files() + + assert result["success"] is True + assert result["new_files"] == 1 + assert result["deleted_files"] == 1 + assert result["action"] == "synced" + + +# =========================================================================== +# Tests: get_index_status +# =========================================================================== + +class TestGetIndexStatus: + """Test get_index_status reporting.""" + + def test_status_with_indexed_files(self, monkeypatch, tmp_path): + """Should report correct counts and categories.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + sub_dir = archive_dir / "utils" + sub_dir.mkdir(parents=True) + + (archive_dir / "a.py").write_text("pass\n", encoding="utf-8") + (sub_dir / "b.py").write_text("pass\n", encoding="utf-8") + + index = { + "metadata": {"last_updated": "2026-01-01", "total_files": 2}, + "categories": {"utils": ["b.py"]}, + "files": { + "a.py": {"filename": "a.py"}, + str(Path("utils") / "b.py"): {"filename": "b.py"}, + }, + } + (archive_dir / "index.json").write_text( + json.dumps(index), encoding="utf-8" + ) + + status = indexer.get_index_status() + + assert status["indexed_files"] == 2 + assert status["current_files"] == 2 + assert status["unindexed"] == 0 + assert "utils" in status["categories"] + assert status["last_updated"] == "2026-01-01" + + def test_status_with_unindexed_files(self, monkeypatch, tmp_path): + """Should report unindexed count when files exist but index is empty.""" + indexer = _import_indexer(monkeypatch, tmp_path) + archive_dir = tmp_path / "code_archive" + archive_dir.mkdir(parents=True) + + (archive_dir / "orphan.py").write_text("pass\n", encoding="utf-8") + + status = indexer.get_index_status() + + assert status["indexed_files"] == 0 + assert status["current_files"] == 1 + assert status["unindexed"] == 1 + assert status["last_updated"] is None + + def test_status_empty(self, monkeypatch, tmp_path): + """Empty archive: all counts zero.""" + indexer = _import_indexer(monkeypatch, tmp_path) + + status = indexer.get_index_status() + + assert status["indexed_files"] == 0 + assert status["current_files"] == 0 + assert status["unindexed"] == 0 + assert status["categories"] == [] diff --git a/src/aipass/memory/tests/test_central_writer.py b/src/aipass/memory/tests/test_central_writer.py new file mode 100644 index 00000000..e7f14684 --- /dev/null +++ b/src/aipass/memory/tests/test_central_writer.py @@ -0,0 +1,508 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_central_writer.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for the central writer handler. + +Covers: + - central_writer.py (count_chroma_vectors, count_archive_files, + get_last_rollover_timestamp, collect_stats, read_central_file, + write_central_file, update_central, get_current_stats) + +All tests use mocks/tmp_path -- no live filesystem or infrastructure access. +""" + +import json +import sqlite3 +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Import helper +# --------------------------------------------------------------------------- + +def _import_central_writer(monkeypatch, tmp_path): + """Import central_writer with mocked dependencies and paths at tmp_path.""" + sys.modules.pop("aipass.memory.apps.handlers.central_writer", None) + parent = sys.modules.get("aipass.memory.apps.handlers") + if parent is not None and hasattr(parent, "central_writer"): + delattr(parent, "central_writer") + + from aipass.memory.apps.handlers import central_writer + + # Redirect all path constants to tmp_path + monkeypatch.setattr(central_writer, "_MEMORY_ROOT", tmp_path) + monkeypatch.setattr(central_writer, "CENTRAL_FILE", tmp_path / "central" / "MEMORY.central.json") + monkeypatch.setattr(central_writer, "CHROMA_DB_PATH", tmp_path / ".chroma") + monkeypatch.setattr(central_writer, "ARCHIVE_DIR", tmp_path / ".archive") + + return central_writer + + +# --------------------------------------------------------------------------- +# Helper: create a tiny SQLite DB with embeddings table +# --------------------------------------------------------------------------- + +def _create_chroma_db(db_path: Path, num_rows: int = 5) -> None: + """Create a minimal SQLite DB mimicking ChromaDB structure.""" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + cursor.execute("CREATE TABLE embeddings (id INTEGER PRIMARY KEY, data TEXT)") + for i in range(num_rows): + cursor.execute("INSERT INTO embeddings (data) VALUES (?)", (f"vec_{i}",)) + conn.commit() + conn.close() + + +# =========================================================================== +# Tests: count_chroma_vectors +# =========================================================================== + +class TestCountChromaVectors: + """Test count_chroma_vectors SQLite reading.""" + + def test_chroma_dir_does_not_exist(self, monkeypatch, tmp_path): + """Should return 0 when .chroma directory is missing.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + assert cw.count_chroma_vectors() == 0 + + def test_chroma_dir_exists_but_no_sqlite(self, monkeypatch, tmp_path): + """Should return 0 when .chroma exists but chroma.sqlite3 is missing.""" + cw = _import_central_writer(monkeypatch, tmp_path) + (tmp_path / ".chroma").mkdir(parents=True) + + assert cw.count_chroma_vectors() == 0 + + def test_with_real_sqlite_db(self, monkeypatch, tmp_path): + """Should count rows from a real sqlite3 DB.""" + cw = _import_central_writer(monkeypatch, tmp_path) + db_file = tmp_path / ".chroma" / "chroma.sqlite3" + _create_chroma_db(db_file, num_rows=7) + + result = cw.count_chroma_vectors() + + assert result == 7 + + def test_empty_embeddings_table(self, monkeypatch, tmp_path): + """Should return 0 when embeddings table exists but is empty.""" + cw = _import_central_writer(monkeypatch, tmp_path) + db_file = tmp_path / ".chroma" / "chroma.sqlite3" + _create_chroma_db(db_file, num_rows=0) + + assert cw.count_chroma_vectors() == 0 + + def test_db_error_returns_zero(self, monkeypatch, tmp_path): + """Should return 0 if sqlite3 query fails.""" + cw = _import_central_writer(monkeypatch, tmp_path) + chroma_dir = tmp_path / ".chroma" + chroma_dir.mkdir(parents=True) + # Write garbage to the sqlite3 file + (chroma_dir / "chroma.sqlite3").write_text("not a database", encoding="utf-8") + + assert cw.count_chroma_vectors() == 0 + + +# =========================================================================== +# Tests: count_archive_files +# =========================================================================== + +class TestCountArchiveFiles: + """Test count_archive_files .md counting.""" + + def test_dir_does_not_exist(self, monkeypatch, tmp_path): + """Should return 0 when .archive directory is missing.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + assert cw.count_archive_files() == 0 + + def test_with_md_files(self, monkeypatch, tmp_path): + """Should count only .md files.""" + cw = _import_central_writer(monkeypatch, tmp_path) + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + + (archive / "session1.md").write_text("# Session 1", encoding="utf-8") + (archive / "session2.md").write_text("# Session 2", encoding="utf-8") + (archive / "notes.txt").write_text("not counted", encoding="utf-8") + + assert cw.count_archive_files() == 2 + + def test_empty_archive_dir(self, monkeypatch, tmp_path): + """Should return 0 when .archive exists but has no .md files.""" + cw = _import_central_writer(monkeypatch, tmp_path) + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + + assert cw.count_archive_files() == 0 + + def test_access_failure_raises(self, monkeypatch, tmp_path): + """Should raise Exception when directory access fails.""" + cw = _import_central_writer(monkeypatch, tmp_path) + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + + # Force glob to fail + monkeypatch.setattr(cw, "ARCHIVE_DIR", archive) + original_glob = Path.glob + + def broken_glob(self, pattern): + raise PermissionError("access denied") + + monkeypatch.setattr(Path, "glob", broken_glob) + + try: + cw.count_archive_files() + assert False, "Expected Exception" + except Exception as exc: + assert "Failed to count archive files" in str(exc) + finally: + monkeypatch.setattr(Path, "glob", original_glob) + + +# =========================================================================== +# Tests: get_last_rollover_timestamp +# =========================================================================== + +class TestGetLastRolloverTimestamp: + """Test get_last_rollover_timestamp file stat reading.""" + + def test_no_archive_dir(self, monkeypatch, tmp_path): + """Should return empty string when .archive does not exist.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + assert cw.get_last_rollover_timestamp() == "" + + def test_no_md_files(self, monkeypatch, tmp_path): + """Should return empty string when .archive has no .md files.""" + cw = _import_central_writer(monkeypatch, tmp_path) + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + + assert cw.get_last_rollover_timestamp() == "" + + def test_with_archive_files(self, monkeypatch, tmp_path): + """Should return ISO timestamp of most recently modified .md file.""" + cw = _import_central_writer(monkeypatch, tmp_path) + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + + (archive / "old.md").write_text("old", encoding="utf-8") + (archive / "new.md").write_text("new", encoding="utf-8") + + result = cw.get_last_rollover_timestamp() + + assert result != "" + # Should be a valid ISO timestamp string + assert "T" in result + + +# =========================================================================== +# Tests: collect_stats +# =========================================================================== + +class TestCollectStats: + """Test collect_stats aggregation.""" + + def test_returns_all_fields(self, monkeypatch, tmp_path): + """Should return dict with total_vectors, total_archives, last_rollover.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + # Set up minimal data + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + (archive / "session.md").write_text("# test", encoding="utf-8") + + stats = cw.collect_stats() + + assert "total_vectors" in stats + assert "total_archives" in stats + assert "last_rollover" in stats + assert stats["total_archives"] == 1 + assert stats["total_vectors"] == 0 # no chroma DB + + def test_all_zeros_when_empty(self, monkeypatch, tmp_path): + """Should return zeros/empty when no data exists.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + stats = cw.collect_stats() + + assert stats["total_vectors"] == 0 + assert stats["total_archives"] == 0 + assert stats["last_rollover"] == "" + + +# =========================================================================== +# Tests: read_central_file +# =========================================================================== + +class TestReadCentralFile: + """Test read_central_file JSON reading.""" + + def test_file_does_not_exist(self, monkeypatch, tmp_path): + """Should return default structure when file is missing.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + result = cw.read_central_file() + + assert result["service"] == "memory_bank" + assert result["last_updated"] == "" + assert result["stats"]["total_vectors"] == 0 + assert result["stats"]["total_archives"] == 0 + + def test_file_exists(self, monkeypatch, tmp_path): + """Should read and return existing JSON contents.""" + cw = _import_central_writer(monkeypatch, tmp_path) + central_dir = tmp_path / "central" + central_dir.mkdir(parents=True) + + data = { + "service": "memory_bank", + "last_updated": "2026-03-01T12:00:00", + "stats": {"total_vectors": 42, "total_archives": 5, "last_rollover": "2026-02-28"}, + "extra_field": "preserved", + } + (central_dir / "MEMORY.central.json").write_text( + json.dumps(data), encoding="utf-8" + ) + + result = cw.read_central_file() + + assert result["stats"]["total_vectors"] == 42 + assert result["extra_field"] == "preserved" + + def test_corrupted_file_raises(self, monkeypatch, tmp_path): + """Should raise Exception when file contains invalid JSON.""" + cw = _import_central_writer(monkeypatch, tmp_path) + central_dir = tmp_path / "central" + central_dir.mkdir(parents=True) + + (central_dir / "MEMORY.central.json").write_text("{{bad json", encoding="utf-8") + + try: + cw.read_central_file() + assert False, "Expected Exception" + except Exception as exc: + assert "Failed to read central file" in str(exc) + + +# =========================================================================== +# Tests: write_central_file +# =========================================================================== + +class TestWriteCentralFile: + """Test write_central_file JSON writing.""" + + def test_creates_dirs_and_writes(self, monkeypatch, tmp_path): + """Should create parent directories and write JSON.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + data = {"service": "memory_bank", "stats": {"total_vectors": 10}} + + cw.write_central_file(data) + + central_file = tmp_path / "central" / "MEMORY.central.json" + assert central_file.exists() + + written = json.loads(central_file.read_text(encoding="utf-8")) + assert written["stats"]["total_vectors"] == 10 + + def test_overwrites_existing_file(self, monkeypatch, tmp_path): + """Should overwrite existing file content.""" + cw = _import_central_writer(monkeypatch, tmp_path) + central_dir = tmp_path / "central" + central_dir.mkdir(parents=True) + + central_file = central_dir / "MEMORY.central.json" + central_file.write_text('{"old": true}', encoding="utf-8") + + cw.write_central_file({"new": True}) + + written = json.loads(central_file.read_text(encoding="utf-8")) + assert written["new"] is True + assert "old" not in written + + def test_write_failure_raises(self, monkeypatch, tmp_path): + """Should raise Exception on write failure.""" + cw = _import_central_writer(monkeypatch, tmp_path) + # Point to impossible path + monkeypatch.setattr(cw, "CENTRAL_FILE", Path("/proc/0/impossible.json")) + + try: + cw.write_central_file({"test": True}) + assert False, "Expected Exception" + except Exception as exc: + assert "Failed to write central file" in str(exc) + + +# =========================================================================== +# Tests: update_central +# =========================================================================== + +class TestUpdateCentral: + """Test update_central end-to-end flow.""" + + def test_success_verbose(self, monkeypatch, tmp_path): + """Verbose mode should include stats in result.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + result = cw.update_central(verbose=True) + + assert result["success"] is True + assert "updated" in result + assert "stats" in result + assert "total_vectors" in result["stats"] + + # Verify file was written + central_file = tmp_path / "central" / "MEMORY.central.json" + assert central_file.exists() + + def test_success_non_verbose(self, monkeypatch, tmp_path): + """Non-verbose mode should NOT include stats in result.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + result = cw.update_central(verbose=False) + + assert result["success"] is True + assert "updated" in result + assert "stats" not in result + + def test_removes_placeholder_note(self, monkeypatch, tmp_path): + """Should remove _note field from central data if present.""" + cw = _import_central_writer(monkeypatch, tmp_path) + central_dir = tmp_path / "central" + central_dir.mkdir(parents=True) + + old_data = { + "service": "memory_bank", + "last_updated": "", + "_note": "placeholder - not yet populated", + "stats": {"total_vectors": 0, "total_archives": 0, "last_rollover": ""}, + } + (central_dir / "MEMORY.central.json").write_text( + json.dumps(old_data), encoding="utf-8" + ) + + result = cw.update_central() + + assert result["success"] is True + + written = json.loads( + (central_dir / "MEMORY.central.json").read_text(encoding="utf-8") + ) + assert "_note" not in written + + def test_preserves_extra_fields(self, monkeypatch, tmp_path): + """Should preserve extra fields from existing central file.""" + cw = _import_central_writer(monkeypatch, tmp_path) + central_dir = tmp_path / "central" + central_dir.mkdir(parents=True) + + old_data = { + "service": "memory_bank", + "last_updated": "2026-01-01", + "custom_field": "keep_me", + "stats": {"total_vectors": 0, "total_archives": 0, "last_rollover": ""}, + } + (central_dir / "MEMORY.central.json").write_text( + json.dumps(old_data), encoding="utf-8" + ) + + result = cw.update_central() + + assert result["success"] is True + + written = json.loads( + (central_dir / "MEMORY.central.json").read_text(encoding="utf-8") + ) + assert written["custom_field"] == "keep_me" + + def test_failure_returns_error(self, monkeypatch, tmp_path): + """Should return success=False with error when collect_stats fails.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + monkeypatch.setattr(cw, "collect_stats", MagicMock(side_effect=RuntimeError("boom"))) + + result = cw.update_central() + + assert result["success"] is False + assert "error" in result + + def test_logs_operation(self, monkeypatch, tmp_path): + """Should call json_handler.log_operation on success.""" + cw = _import_central_writer(monkeypatch, tmp_path) + mock_handler: MagicMock = sys.modules["aipass.memory.apps.handlers.json"].json_handler + + cw.update_central() + + mock_handler.log_operation.assert_called_once() + call_args = mock_handler.log_operation.call_args + assert call_args[0][0] == "update_central" + + +# =========================================================================== +# Tests: get_current_stats +# =========================================================================== + +class TestGetCurrentStats: + """Test get_current_stats read-only stats collection.""" + + def test_success(self, monkeypatch, tmp_path): + """Should return success=True with stats fields.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + result = cw.get_current_stats() + + assert result["success"] is True + assert "total_vectors" in result + assert "total_archives" in result + assert "last_rollover" in result + + def test_does_not_write_file(self, monkeypatch, tmp_path): + """Should NOT create or modify the central file.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + cw.get_current_stats() + + central_file = tmp_path / "central" / "MEMORY.central.json" + assert not central_file.exists() + + def test_failure_returns_error(self, monkeypatch, tmp_path): + """Should return success=False with error when stats collection fails.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + monkeypatch.setattr(cw, "collect_stats", MagicMock(side_effect=RuntimeError("db error"))) + + result = cw.get_current_stats() + + assert result["success"] is False + assert "error" in result + assert "db error" in result["error"] + + def test_with_real_data(self, monkeypatch, tmp_path): + """Should return actual counts from real test data.""" + cw = _import_central_writer(monkeypatch, tmp_path) + + # Set up chroma DB + db_file = tmp_path / ".chroma" / "chroma.sqlite3" + _create_chroma_db(db_file, num_rows=3) + + # Set up archive files + archive = tmp_path / ".archive" + archive.mkdir(parents=True) + (archive / "s1.md").write_text("session 1", encoding="utf-8") + (archive / "s2.md").write_text("session 2", encoding="utf-8") + + result = cw.get_current_stats() + + assert result["success"] is True + assert result["total_vectors"] == 3 + assert result["total_archives"] == 2 + assert result["last_rollover"] != "" diff --git a/src/aipass/memory/tests/test_dashboard_push.py b/src/aipass/memory/tests/test_dashboard_push.py new file mode 100644 index 00000000..9a5e358a --- /dev/null +++ b/src/aipass/memory/tests/test_dashboard_push.py @@ -0,0 +1,616 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_dashboard_push.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for the dashboard_push handler. + +Covers: + - dashboard_push._read_central_stats (missing file, valid file) + - dashboard_push._get_collections_count (missing DB, valid DB) + - dashboard_push._get_rollover_config (missing config, valid config) + - dashboard_push._get_max_lines_for_branch (override vs default) + - dashboard_push._find_branches_near_rollover (v1 near threshold, v2 near session limit) + - dashboard_push._get_template_version (missing file, valid file) + - dashboard_push._get_last_rollover_info (with timestamp, empty string) + - dashboard_push._get_all_branch_paths (with registry) + - dashboard_push.build_memory_bank_section (integration via mocked helpers) + - dashboard_push.push_memory_bank_dashboard (subprocess + build mock) + +All tests use mocks/tmp_path -- no live filesystem or infrastructure access. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Import helper +# --------------------------------------------------------------------------- + +def _import_dashboard_push(monkeypatch): + """Import dashboard_push with mocked dependencies.""" + sys.modules.pop("aipass.memory.apps.handlers.dashboard_push", None) + parent = sys.modules.get("aipass.memory.apps.handlers") + if parent is not None and hasattr(parent, "dashboard_push"): + delattr(parent, "dashboard_push") + + from aipass.memory.apps.handlers import dashboard_push + + return dashboard_push + + +# =========================================================================== +# Tests: _read_central_stats +# =========================================================================== + +class TestReadCentralStats: + """Test _read_central_stats helper.""" + + def test_returns_defaults_when_file_missing(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "CENTRAL_FILE", tmp_path / "nonexistent.json") + + result = mod._read_central_stats() + + assert result == {"total_vectors": 0, "total_archives": 0, "last_rollover": ""} + + def test_reads_valid_central_file(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + central = tmp_path / "MEMORY.central.json" + central.write_text(json.dumps({ + "stats": { + "total_vectors": 1500, + "total_archives": 12, + "last_rollover": "2026-03-15T10:30:00" + } + }), encoding="utf-8") + monkeypatch.setattr(mod, "CENTRAL_FILE", central) + + result = mod._read_central_stats() + + assert result["total_vectors"] == 1500 + assert result["total_archives"] == 12 + assert result["last_rollover"] == "2026-03-15T10:30:00" + + def test_returns_defaults_on_corrupt_json(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + central = tmp_path / "MEMORY.central.json" + central.write_text("not valid json{{{", encoding="utf-8") + monkeypatch.setattr(mod, "CENTRAL_FILE", central) + + result = mod._read_central_stats() + + assert result == {"total_vectors": 0, "total_archives": 0, "last_rollover": ""} + + +# =========================================================================== +# Tests: _get_collections_count +# =========================================================================== + +class TestGetCollectionsCount: + """Test _get_collections_count helper.""" + + def test_returns_zero_when_no_db(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "_MEMORY_ROOT", tmp_path) + + result = mod._get_collections_count() + + assert result == 0 + + def test_reads_count_from_sqlite(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "_MEMORY_ROOT", tmp_path) + + import sqlite3 + + chroma_dir = tmp_path / ".chroma" + chroma_dir.mkdir() + db_path = chroma_dir / "chroma.sqlite3" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE collections (id INTEGER PRIMARY KEY, name TEXT)") + conn.execute("INSERT INTO collections VALUES (1, 'coll_a')") + conn.execute("INSERT INTO collections VALUES (2, 'coll_b')") + conn.execute("INSERT INTO collections VALUES (3, 'coll_c')") + conn.commit() + conn.close() + + result = mod._get_collections_count() + + assert result == 3 + + +# =========================================================================== +# Tests: _get_rollover_config +# =========================================================================== + +class TestGetRolloverConfig: + """Test _get_rollover_config helper.""" + + def test_returns_defaults_when_no_config(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "CONFIG_PATH", tmp_path / "missing_config.json") + + result = mod._get_rollover_config() + + assert result == {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + + def test_loads_valid_config(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + config_file = tmp_path / "memory_bank.config.json" + config_file.write_text(json.dumps({ + "rollover": { + "defaults": {"max_lines": 800, "buffer": 150}, + "per_branch": { + "NEXUS": {"max_lines": 1200} + } + } + }), encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_PATH", config_file) + + result = mod._get_rollover_config() + + assert result["defaults"]["max_lines"] == 800 + assert result["defaults"]["buffer"] == 150 + assert result["per_branch"]["NEXUS"]["max_lines"] == 1200 + + def test_returns_defaults_on_corrupt_config(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + config_file = tmp_path / "broken.json" + config_file.write_text("{invalid", encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_PATH", config_file) + + result = mod._get_rollover_config() + + assert result == {"defaults": {"max_lines": 600, "buffer": 100}, "per_branch": {}} + + +# =========================================================================== +# Tests: _get_max_lines_for_branch +# =========================================================================== + +class TestGetMaxLinesForBranch: + """Test _get_max_lines_for_branch helper.""" + + def test_returns_default_when_no_override(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + config = {"defaults": {"max_lines": 600}, "per_branch": {}} + + result = mod._get_max_lines_for_branch("DEVPULSE", config) + + assert result == 600 + + def test_returns_override_when_present(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + config = { + "defaults": {"max_lines": 600}, + "per_branch": {"NEXUS": {"max_lines": 1200}} + } + + result = mod._get_max_lines_for_branch("NEXUS", config) + + assert result == 1200 + + def test_falls_back_to_default_when_override_missing_max_lines(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + config = { + "defaults": {"max_lines": 600}, + "per_branch": {"DRONE": {"buffer": 200}} + } + + result = mod._get_max_lines_for_branch("DRONE", config) + + assert result == 600 + + +# =========================================================================== +# Tests: _find_branches_near_rollover +# =========================================================================== + +class TestFindBranchesNearRollover: + """Test _find_branches_near_rollover helper.""" + + def test_returns_empty_when_no_registry(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "AIPASS_REGISTRY", tmp_path / "no_registry.json") + + result = mod._find_branches_near_rollover() + + assert result == [] + + def test_v1_file_near_threshold(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + # Build branch with a v1 memory file near rollover + branch_dir = tmp_path / "src" / "aipass" / "test_branch" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + (trinity / "local.json").write_text(json.dumps({ + "document_metadata": { + "schema_version": "1.0.0", + "status": {"current_lines": 550} + } + }), encoding="utf-8") + + # Registry pointing to branch + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "TEST_BRANCH", "path": str(branch_dir)} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + + # Config: max_lines=600, so 600-550=50 remaining (<100 threshold) + monkeypatch.setattr(mod, "_get_rollover_config", lambda: { + "defaults": {"max_lines": 600, "buffer": 100}, + "per_branch": {} + }) + monkeypatch.setattr(mod, "NEAR_ROLLOVER_THRESHOLD", 100) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._find_branches_near_rollover() + + assert len(result) == 1 + assert result[0]["branch"] == "TEST_BRANCH" + assert result[0]["file_type"] == "local" + assert result[0]["lines_remaining"] == 50 + assert result[0]["current_lines"] == 550 + + def test_v1_file_not_near_threshold(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + branch_dir = tmp_path / "src" / "aipass" / "safe_branch" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + (trinity / "local.json").write_text(json.dumps({ + "document_metadata": { + "schema_version": "1.0.0", + "status": {"current_lines": 200} + } + }), encoding="utf-8") + + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "SAFE_BRANCH", "path": str(branch_dir)} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + monkeypatch.setattr(mod, "_get_rollover_config", lambda: { + "defaults": {"max_lines": 600, "buffer": 100}, + "per_branch": {} + }) + monkeypatch.setattr(mod, "NEAR_ROLLOVER_THRESHOLD", 100) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._find_branches_near_rollover() + + assert result == [] + + def test_v2_file_near_session_limit(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + branch_dir = tmp_path / "src" / "aipass" / "v2_branch" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + (trinity / "local.json").write_text(json.dumps({ + "document_metadata": { + "schema_version": "2.0.0", + "limits": {"max_sessions": 20, "max_key_learnings": 25} + }, + "sessions": [{"id": i} for i in range(19)], # 19 of 20 sessions + "key_learnings": {"k1": "v1"} + }), encoding="utf-8") + + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "V2_BRANCH", "path": str(branch_dir)} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + monkeypatch.setattr(mod, "_get_rollover_config", lambda: { + "defaults": {"max_lines": 600, "buffer": 100}, + "per_branch": {} + }) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._find_branches_near_rollover() + + # sessions: 20-19=1 remaining (<3) -> reported + # key_learnings: 25-1=24 remaining (>=3) -> not reported + assert len(result) == 1 + assert result[0]["branch"] == "V2_BRANCH" + assert result[0]["v2_field"] == "sessions" + assert result[0]["lines_remaining"] == 1 + + def test_v2_file_near_key_learnings_limit(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + branch_dir = tmp_path / "src" / "aipass" / "kl_branch" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + (trinity / "local.json").write_text(json.dumps({ + "document_metadata": { + "schema_version": "2.0.0", + "limits": {"max_sessions": 20, "max_key_learnings": 5} + }, + "sessions": [{"id": 1}], + "key_learnings": {f"k{i}": f"v{i}" for i in range(4)} # 4 of 5 + }), encoding="utf-8") + + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "KL_BRANCH", "path": str(branch_dir)} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + monkeypatch.setattr(mod, "_get_rollover_config", lambda: { + "defaults": {"max_lines": 600, "buffer": 100}, + "per_branch": {} + }) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._find_branches_near_rollover() + + # key_learnings: 5-4=1 remaining (<3) -> reported + assert len(result) == 1 + assert result[0]["v2_field"] == "key_learnings" + assert result[0]["lines_remaining"] == 1 + + def test_skips_nonexistent_branch_paths(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "GHOST", "path": str(tmp_path / "nonexistent")} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + monkeypatch.setattr(mod, "_get_rollover_config", lambda: { + "defaults": {"max_lines": 600, "buffer": 100}, + "per_branch": {} + }) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._find_branches_near_rollover() + + assert result == [] + + +# =========================================================================== +# Tests: _get_template_version +# =========================================================================== + +class TestGetTemplateVersion: + """Test _get_template_version helper.""" + + def test_returns_unknown_when_file_missing(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "TEMPLATE_VERSION_FILE", tmp_path / "nope.json") + + result = mod._get_template_version() + + assert result == "unknown" + + def test_reads_valid_version(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + version_file = tmp_path / ".template_version.json" + version_file.write_text(json.dumps({"version": "2.0.4"}), encoding="utf-8") + monkeypatch.setattr(mod, "TEMPLATE_VERSION_FILE", version_file) + + result = mod._get_template_version() + + assert result == "2.0.4" + + def test_returns_unknown_when_version_key_absent(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + version_file = tmp_path / ".template_version.json" + version_file.write_text(json.dumps({"name": "templates"}), encoding="utf-8") + monkeypatch.setattr(mod, "TEMPLATE_VERSION_FILE", version_file) + + result = mod._get_template_version() + + assert result == "unknown" + + +# =========================================================================== +# Tests: _get_last_rollover_info +# =========================================================================== + +class TestGetLastRolloverInfo: + """Test _get_last_rollover_info helper.""" + + def test_parses_iso_timestamp(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + stats = {"last_rollover": "2026-03-15T10:30:00"} + + result = mod._get_last_rollover_info(stats) + + assert result == {"date": "2026-03-15"} + + def test_returns_never_for_empty_string(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + stats = {"last_rollover": ""} + + result = mod._get_last_rollover_info(stats) + + assert result == {"date": "never"} + + def test_returns_never_when_key_missing(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + stats = {} + + result = mod._get_last_rollover_info(stats) + + assert result == {"date": "never"} + + def test_returns_raw_string_on_unparseable_timestamp(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + stats = {"last_rollover": "some-invalid-date"} + + result = mod._get_last_rollover_info(stats) + + assert result == {"date": "some-invalid-date"} + + +# =========================================================================== +# Tests: _get_all_branch_paths +# =========================================================================== + +class TestGetAllBranchPaths: + """Test _get_all_branch_paths helper.""" + + def test_returns_empty_when_no_registry(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + monkeypatch.setattr(mod, "AIPASS_REGISTRY", tmp_path / "no_registry.json") + + result = mod._get_all_branch_paths() + + assert result == [] + + def test_returns_existing_branch_paths(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + branch_a = tmp_path / "branch_a" + branch_b = tmp_path / "branch_b" + branch_a.mkdir() + branch_b.mkdir() + + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "A", "path": str(branch_a)}, + {"name": "B", "path": str(branch_b)}, + {"name": "C", "path": str(tmp_path / "nonexistent")} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._get_all_branch_paths() + + assert len(result) == 2 + assert branch_a in result + assert branch_b in result + + def test_resolves_relative_paths(self, monkeypatch, tmp_path): + mod = _import_dashboard_push(monkeypatch) + + branch_dir = tmp_path / "src" / "aipass" / "test_branch" + branch_dir.mkdir(parents=True) + + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text(json.dumps({ + "branches": [ + {"name": "TEST", "path": "src/aipass/test_branch"} + ] + }), encoding="utf-8") + monkeypatch.setattr(mod, "AIPASS_REGISTRY", registry) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + + result = mod._get_all_branch_paths() + + assert len(result) == 1 + assert result[0] == branch_dir + + +# =========================================================================== +# Tests: build_memory_bank_section (public) +# =========================================================================== + +class TestBuildMemoryBankSection: + """Test build_memory_bank_section with mocked helpers.""" + + def test_assembles_section_data(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + + monkeypatch.setattr(mod, "_read_central_stats", lambda: { + "total_vectors": 2500, + "total_archives": 15, + "last_rollover": "2026-03-20T12:00:00" + }) + monkeypatch.setattr(mod, "_find_branches_near_rollover", lambda: [ + {"branch": "NEXUS", "file_type": "local", "lines_remaining": 30} + ]) + monkeypatch.setattr(mod, "_get_last_rollover_info", lambda s: {"date": "2026-03-20"}) + monkeypatch.setattr(mod, "_get_template_version", lambda: "2.0.4") + monkeypatch.setattr(mod, "_get_collections_count", lambda: 8) + + result = mod.build_memory_bank_section() + + assert result["managed_by"] == "memory_bank" + assert result["total_vectors"] == 2500 + assert result["collections_count"] == 8 + assert len(result["branches_near_rollover"]) == 1 + assert result["branches_near_rollover"][0]["branch"] == "NEXUS" + assert result["last_rollover"] == {"date": "2026-03-20"} + assert result["template_version"] == "2.0.4" + + +# =========================================================================== +# Tests: push_memory_bank_dashboard (public) +# =========================================================================== + +class TestPushMemoryBankDashboard: + """Test push_memory_bank_dashboard.""" + + def test_returns_true_when_at_least_one_updated(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + + mock_section = {"managed_by": "memory_bank", "total_vectors": 100} + monkeypatch.setattr(mod, "build_memory_bank_section", lambda: mock_section) + monkeypatch.setattr(mod, "_get_all_branch_paths", lambda: [Path("/tmp/a")]) + monkeypatch.setattr(mod, "_write_section_to_all_branches", lambda name, data, paths: 1) + + result = mod.push_memory_bank_dashboard() + + assert result is True + + def test_returns_false_when_no_dashboards_updated(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + + monkeypatch.setattr(mod, "build_memory_bank_section", lambda: {"managed_by": "memory_bank"}) + monkeypatch.setattr(mod, "_get_all_branch_paths", lambda: []) + monkeypatch.setattr(mod, "_write_section_to_all_branches", lambda name, data, paths: 0) + + result = mod.push_memory_bank_dashboard() + + assert result is False + + def test_logs_operation_on_success(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + + monkeypatch.setattr(mod, "build_memory_bank_section", lambda: {"managed_by": "memory_bank"}) + monkeypatch.setattr(mod, "_get_all_branch_paths", lambda: [Path("/tmp/a")]) + monkeypatch.setattr(mod, "_write_section_to_all_branches", lambda name, data, paths: 3) + + mock_jh = MagicMock() + monkeypatch.setattr(mod, "json_handler", mock_jh) + + mod.push_memory_bank_dashboard() + + mock_jh.log_operation.assert_called_once_with( + "dashboard_push", + {"branches_updated": 3, "success": True} + ) + + def test_returns_false_on_exception(self, monkeypatch): + mod = _import_dashboard_push(monkeypatch) + + monkeypatch.setattr(mod, "build_memory_bank_section", MagicMock( + side_effect=RuntimeError("boom") + )) + + result = mod.push_memory_bank_dashboard() + + assert result is False diff --git a/src/aipass/memory/tests/test_intake.py b/src/aipass/memory/tests/test_intake.py new file mode 100644 index 00000000..99097395 --- /dev/null +++ b/src/aipass/memory/tests/test_intake.py @@ -0,0 +1,691 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_intake.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for the intake/pool_processor handler. + +Covers: + - pool_processor.find_source_file (active pool, archive, not found) + - pool_processor.load_config (success, missing file) + - pool_processor.get_pool_files (no files, sorted by mtime) + - pool_processor.read_file_content (success, failure) + - pool_processor.chunk_content (short text, long text, paragraph breaks) + - pool_processor.process_file_to_vectors (mocked chromadb) + - pool_processor.archive_old_files (under limit, moves old, duplicate names) + - pool_processor.process_memory_pool (disabled, no files) + - pool_processor.get_pool_status (mocked chromadb) + +All tests use mocks/tmp_path -- no live filesystem or infrastructure access. +""" + +import json +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Import helper +# --------------------------------------------------------------------------- + +def _import_pool_processor(monkeypatch): + """Import pool_processor with mocked dependencies.""" + sys.modules.pop("aipass.memory.apps.handlers.intake.pool_processor", None) + parent = sys.modules.get("aipass.memory.apps.handlers.intake") + if parent is not None and hasattr(parent, "pool_processor"): + delattr(parent, "pool_processor") + + from aipass.memory.apps.handlers.intake import pool_processor + + return pool_processor + + +# =========================================================================== +# Tests: find_source_file +# =========================================================================== + +class TestFindSourceFile: + """Test find_source_file function.""" + + def test_found_in_active_pool(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + target = pool / "notes.md" + target.write_text("content", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.find_source_file("notes.md") + + assert result == target + + def test_found_in_archive(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + archive = pool / ".archive" + archive.mkdir(parents=True) + target = archive / "old_notes.md" + target.write_text("archived content", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.find_source_file("old_notes.md") + + assert result == target + + def test_not_found_returns_none(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.find_source_file("nonexistent.md") + + assert result is None + + def test_prefers_active_over_archive(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + archive = pool / ".archive" + archive.mkdir(parents=True) + active_file = pool / "notes.md" + archive_file = archive / "notes.md" + active_file.write_text("active", encoding="utf-8") + archive_file.write_text("archived", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.find_source_file("notes.md") + + assert result == active_file + + +# =========================================================================== +# Tests: load_config +# =========================================================================== + +class TestLoadConfig: + """Test load_config function.""" + + def test_loads_valid_config(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + config_file = tmp_path / "memory_bank.config.json" + config_file.write_text(json.dumps({ + "memory_pool": { + "enabled": True, + "keep_recent": 5, + "collection_name": "test_pool" + } + }), encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_PATH", config_file) + + result = mod.load_config() + + assert result["enabled"] is True + assert result["keep_recent"] == 5 + assert result["collection_name"] == "test_pool" + + def test_returns_disabled_when_file_missing(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + monkeypatch.setattr(mod, "CONFIG_PATH", tmp_path / "missing.json") + + result = mod.load_config() + + assert result["enabled"] is False + assert "error" in result + + def test_returns_empty_when_no_memory_pool_key(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + config_file = tmp_path / "memory_bank.config.json" + config_file.write_text(json.dumps({"rollover": {}}), encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_PATH", config_file) + + result = mod.load_config() + + assert result == {} + + +# =========================================================================== +# Tests: get_pool_files +# =========================================================================== + +class TestGetPoolFiles: + """Test get_pool_files function.""" + + def test_returns_empty_when_no_directory(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", tmp_path / "nonexistent") + + result = mod.get_pool_files() + + assert result == [] + + def test_returns_empty_when_no_matching_files(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + (pool / "image.png").write_text("not text", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.get_pool_files() + + assert result == [] + + def test_returns_sorted_by_mtime_newest_first(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + + old_file = pool / "old.md" + old_file.write_text("old content", encoding="utf-8") + import os + os.utime(str(old_file), (1000000, 1000000)) + + new_file = pool / "new.md" + new_file.write_text("new content", encoding="utf-8") + os.utime(str(new_file), (2000000, 2000000)) + + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.get_pool_files() + + assert len(result) == 2 + assert result[0].name == "new.md" + assert result[1].name == "old.md" + + def test_filters_by_custom_extensions(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + (pool / "doc.md").write_text("md", encoding="utf-8") + (pool / "notes.txt").write_text("txt", encoding="utf-8") + (pool / "data.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + result = mod.get_pool_files(extensions=[".json"]) + + assert len(result) == 1 + assert result[0].name == "data.json" + + +# =========================================================================== +# Tests: read_file_content +# =========================================================================== + +class TestReadFileContent: + """Test read_file_content function.""" + + def test_reads_successfully(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + test_file = tmp_path / "test.md" + test_file.write_text("Hello, world!", encoding="utf-8") + + result = mod.read_file_content(test_file) + + assert result["success"] is True + assert result["content"] == "Hello, world!" + assert result["metadata"]["filename"] == "test.md" + assert result["metadata"]["extension"] == ".md" + assert result["metadata"]["size"] > 0 + + def test_returns_failure_for_missing_file(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + missing = tmp_path / "nonexistent.md" + + result = mod.read_file_content(missing) + + assert result["success"] is False + assert "error" in result + + +# =========================================================================== +# Tests: chunk_content +# =========================================================================== + +class TestChunkContent: + """Test chunk_content function.""" + + def test_short_text_single_chunk(self, monkeypatch): + mod = _import_pool_processor(monkeypatch) + + result = mod.chunk_content("Short text.", chunk_size=1000) + + assert len(result) == 1 + assert result[0]["text"] == "Short text." + assert result[0]["chunk_index"] == 0 + + def test_long_text_multiple_chunks(self, monkeypatch): + mod = _import_pool_processor(monkeypatch) + # Create text longer than chunk_size + content = "word " * 300 # ~1500 chars + + result = mod.chunk_content(content, chunk_size=500, overlap=50) + + assert len(result) > 1 + # All chunks have sequential indices + indices = [c["chunk_index"] for c in result] + assert indices == list(range(len(result))) + + def test_chunk_indices_are_sequential(self, monkeypatch): + mod = _import_pool_processor(monkeypatch) + content = "A" * 2500 + + result = mod.chunk_content(content, chunk_size=1000, overlap=100) + + for i, chunk in enumerate(result): + assert chunk["chunk_index"] == i + + def test_paragraph_break_splitting(self, monkeypatch): + mod = _import_pool_processor(monkeypatch) + # Build content with a paragraph break in the right spot + # chunk_size=100, so we need content > 100 chars + # Place a paragraph break after the midpoint (>50 chars in) + first_para = "A" * 70 + second_para = "B" * 70 + content = first_para + "\n\n" + second_para + + result = mod.chunk_content(content, chunk_size=100, overlap=10) + + # Should have split at the paragraph break + assert len(result) >= 2 + + def test_empty_content_returns_single_chunk(self, monkeypatch): + mod = _import_pool_processor(monkeypatch) + + result = mod.chunk_content("", chunk_size=1000) + + # Empty string <= chunk_size, returns single chunk + assert len(result) == 1 + assert result[0]["text"] == "" + + def test_exact_chunk_size_single_chunk(self, monkeypatch): + mod = _import_pool_processor(monkeypatch) + content = "X" * 100 + + result = mod.chunk_content(content, chunk_size=100, overlap=10) + + assert len(result) == 1 + assert result[0]["text"] == content + + +# =========================================================================== +# Tests: process_file_to_vectors +# =========================================================================== + +class TestProcessFileToVectors: + """Test process_file_to_vectors with mocked chromadb.""" + + def test_processes_file_with_mocked_chromadb(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") + + test_file = tmp_path / "test.md" + test_file.write_text("Test content for vectorization.", encoding="utf-8") + + # Mock chromadb + mock_collection = MagicMock() + mock_collection.upsert = MagicMock() + mock_client = MagicMock() + mock_client.get_or_create_collection.return_value = mock_collection + mock_chromadb = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + + # Mock sentence_transformers + mock_model = MagicMock() + mock_model.encode.return_value = MagicMock(tolist=MagicMock(return_value=[[0.1, 0.2]])) + mock_st = MagicMock() + mock_st.SentenceTransformer.return_value = mock_model + + monkeypatch.setitem(sys.modules, "chromadb", mock_chromadb) + monkeypatch.setitem(sys.modules, "sentence_transformers", mock_st) + + result = mod.process_file_to_vectors(test_file, "test_collection") + + assert result["success"] is True + assert result["chunks_stored"] >= 1 + assert result["collection"] == "test_collection" + mock_collection.upsert.assert_called_once() + + def test_returns_failure_when_file_unreadable(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + missing = tmp_path / "nonexistent.md" + + result = mod.process_file_to_vectors(missing, "test_collection") + + assert result["success"] is False + + def test_returns_failure_when_chromadb_import_fails(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + + test_file = tmp_path / "test.md" + test_file.write_text("Some content here.", encoding="utf-8") + + # Remove chromadb from modules so the import inside the function fails + monkeypatch.delitem(sys.modules, "chromadb", raising=False) + monkeypatch.delitem(sys.modules, "sentence_transformers", raising=False) + + # Patch the builtins __import__ to raise for chromadb + original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ + + def fake_import(name, *args, **kwargs): + if name == "chromadb": + raise ImportError("chromadb not installed") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + + result = mod.process_file_to_vectors(test_file, "test_collection") + + assert result["success"] is False + assert "error" in result + + +# =========================================================================== +# Tests: archive_old_files +# =========================================================================== + +class TestArchiveOldFiles: + """Test archive_old_files function.""" + + def test_no_archiving_when_under_limit(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + (pool / "file1.md").write_text("content1", encoding="utf-8") + (pool / "file2.md").write_text("content2", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "_MEMORY_ROOT", tmp_path) + + # Mock load_config to return supported extensions + monkeypatch.setattr(mod, "load_config", lambda: { + "supported_extensions": [".md", ".txt"] + }) + + result = mod.archive_old_files(keep_recent=5) + + assert result["success"] is True + assert result["archived_count"] == 0 + assert result["kept_count"] == 2 + + def test_moves_old_files_to_archive(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "_MEMORY_ROOT", tmp_path) + + import os + + # Create 4 files with distinct mtimes + for i in range(4): + f = pool / f"file{i}.md" + f.write_text(f"content {i}", encoding="utf-8") + os.utime(str(f), (1000000 + (3 - i) * 1000, 1000000 + (3 - i) * 1000)) + + monkeypatch.setattr(mod, "load_config", lambda: { + "supported_extensions": [".md"] + }) + + archive_dir_name = "test_archive" + result = mod.archive_old_files(keep_recent=2, archive_path=archive_dir_name) + + assert result["success"] is True + assert result["archived_count"] == 2 + assert result["kept_count"] == 2 + + archive_dir = tmp_path / archive_dir_name + assert archive_dir.exists() + archived_files = list(archive_dir.iterdir()) + assert len(archived_files) == 2 + + def test_handles_duplicate_names_in_archive(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "_MEMORY_ROOT", tmp_path) + + import os + + # Create files + for i in range(3): + f = pool / f"file{i}.md" + f.write_text(f"content {i}", encoding="utf-8") + os.utime(str(f), (1000000 + (2 - i) * 1000, 1000000 + (2 - i) * 1000)) + + # Pre-create a file in archive with the same name as one that will be archived + archive_dir = tmp_path / "test_archive" + archive_dir.mkdir() + (archive_dir / "file2.md").write_text("pre-existing", encoding="utf-8") + + monkeypatch.setattr(mod, "load_config", lambda: { + "supported_extensions": [".md"] + }) + + result = mod.archive_old_files(keep_recent=1, archive_path="test_archive") + + assert result["success"] is True + assert result["archived_count"] == 2 + + # All files in archive (pre-existing + 2 moved, one renamed with timestamp) + archived = list(archive_dir.iterdir()) + assert len(archived) == 3 + + +# =========================================================================== +# Tests: process_memory_pool +# =========================================================================== + +class TestProcessMemoryPool: + """Test process_memory_pool main entry point.""" + + def test_returns_error_when_disabled(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", tmp_path / "pool") + monkeypatch.setattr(mod, "load_config", lambda: {"enabled": False}) + + result = mod.process_memory_pool() + + assert result["success"] is False + assert "disabled" in result["error"] + + def test_returns_success_with_no_files(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "pool" + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "load_config", lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test", + "chunk_size": 1000, + "chunk_overlap": 100, + "supported_extensions": [".md"], + "archive_path": "archive" + }) + monkeypatch.setattr(mod, "get_pool_files", lambda extensions=None: []) + + result = mod.process_memory_pool() + + assert result["success"] is True + assert result["files_processed"] == 0 + + def test_processes_files_and_archives(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "pool" + pool.mkdir(parents=True) + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + test_file = tmp_path / "test.md" + test_file.write_text("content", encoding="utf-8") + + monkeypatch.setattr(mod, "load_config", lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test", + "chunk_size": 1000, + "chunk_overlap": 100, + "supported_extensions": [".md"], + "archive_path": "archive" + }) + monkeypatch.setattr(mod, "get_pool_files", lambda extensions=None: [test_file]) + monkeypatch.setattr(mod, "process_file_to_vectors", lambda fp, cn, cs=1000, co=100: { + "success": True, + "file": fp.name, + "chunks_stored": 3, + "collection": cn + }) + monkeypatch.setattr(mod, "archive_old_files", lambda keep, archive_path="": { + "success": True, + "archived_count": 0, + "kept_count": 1 + }) + monkeypatch.setattr(mod, "_update_central_and_dashboard", lambda: None) + + mock_jh = MagicMock() + monkeypatch.setattr(mod, "json_handler", mock_jh) + + result = mod.process_memory_pool() + + assert result["success"] is True + assert result["files_processed"] == 1 + assert result["total_chunks"] == 3 + mock_jh.log_operation.assert_called_once() + + def test_reports_errors_and_notifies(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "pool" + pool.mkdir(parents=True) + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + + test_file = tmp_path / "bad.md" + test_file.write_text("content", encoding="utf-8") + + monkeypatch.setattr(mod, "load_config", lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test", + "chunk_size": 1000, + "chunk_overlap": 100, + "supported_extensions": [".md"], + "archive_path": "archive" + }) + monkeypatch.setattr(mod, "get_pool_files", lambda extensions=None: [test_file]) + monkeypatch.setattr(mod, "process_file_to_vectors", lambda fp, cn, cs=1000, co=100: { + "success": False, + "error": "chromadb failed" + }) + monkeypatch.setattr(mod, "archive_old_files", lambda keep, archive_path="": { + "success": True, + "archived_count": 0, + "kept_count": 0 + }) + mock_notify = MagicMock() + monkeypatch.setattr(mod, "_notify_failure", mock_notify) + + mock_jh = MagicMock() + monkeypatch.setattr(mod, "json_handler", mock_jh) + + result = mod.process_memory_pool() + + assert result["success"] is False + assert len(result["errors"]) == 1 + mock_notify.assert_called_once() + + +# =========================================================================== +# Tests: get_pool_status +# =========================================================================== + +class TestGetPoolStatus: + """Test get_pool_status function.""" + + def test_returns_status_with_mocked_chromadb(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + (pool / "recent.md").write_text("content", encoding="utf-8") + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") + + monkeypatch.setattr(mod, "load_config", lambda: { + "enabled": True, + "keep_recent": 10, + "collection_name": "test_pool", + "supported_extensions": [".md"] + }) + + # Mock chromadb + mock_collection = MagicMock() + mock_collection.name = "test_pool" + mock_collection.count.return_value = 42 + mock_client = MagicMock() + mock_client.list_collections.return_value = [mock_collection] + mock_client.get_collection.return_value = mock_collection + mock_chromadb = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + monkeypatch.setitem(sys.modules, "chromadb", mock_chromadb) + + result = mod.get_pool_status() + + assert result["enabled"] is True + assert result["files_in_pool"] == 1 + assert result["vectors_stored"] == 42 + assert result["collection_name"] == "test_pool" + assert result["newest_file"] == "recent.md" + assert result["oldest_file"] == "recent.md" + + def test_returns_zero_vectors_when_chromadb_fails(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") + + monkeypatch.setattr(mod, "load_config", lambda: { + "enabled": False, + "supported_extensions": [".md"] + }) + + # Make chromadb import raise + original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ + + def fake_import(name, *args, **kwargs): + if name == "chromadb": + raise ImportError("no chromadb") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + + result = mod.get_pool_status() + + assert result["vectors_stored"] == 0 + assert result["files_in_pool"] == 0 + assert result["newest_file"] is None + assert result["oldest_file"] is None + + def test_returns_zero_vectors_when_collection_not_found(self, monkeypatch, tmp_path): + mod = _import_pool_processor(monkeypatch) + pool = tmp_path / "memory_pool" + pool.mkdir() + monkeypatch.setattr(mod, "MEMORY_POOL_PATH", pool) + monkeypatch.setattr(mod, "CHROMA_PATH", tmp_path / ".chroma") + + monkeypatch.setattr(mod, "load_config", lambda: { + "enabled": True, + "supported_extensions": [".md"], + "collection_name": "test_pool" + }) + + # chromadb returns empty collection list + mock_client = MagicMock() + mock_client.list_collections.return_value = [] + mock_chromadb = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + monkeypatch.setitem(sys.modules, "chromadb", mock_chromadb) + + result = mod.get_pool_status() + + assert result["vectors_stored"] == 0 diff --git a/src/aipass/memory/tests/test_learnings.py b/src/aipass/memory/tests/test_learnings.py new file mode 100644 index 00000000..1b7e4a79 --- /dev/null +++ b/src/aipass/memory/tests/test_learnings.py @@ -0,0 +1,662 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_learnings.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for the learnings manager handler. + +Covers: + parse_timestamp, add_timestamp, get_entry_age, + get_max_learnings, get_max_recently_completed, + ensure_timestamps, enforce_limit, + ensure_timestamps_completed, enforce_limit_completed, + add_learning, update_status_counts, process_file + +All tests use mocks or tmp_path -- no live filesystem or infrastructure access. +""" + +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helper: import learnings manager with mocked dependencies +# --------------------------------------------------------------------------- + +def _import_learnings_manager(monkeypatch): + """Import manager with mocked dependencies.""" + # Mock memory_files since it's imported at module level + mock_memory_files = MagicMock() + monkeypatch.setitem( + sys.modules, + "aipass.memory.apps.handlers.json.memory_files", + mock_memory_files, + ) + + sys.modules.pop("aipass.memory.apps.handlers.learnings.manager", None) + parent = sys.modules.get("aipass.memory.apps.handlers.learnings") + if parent is not None and hasattr(parent, "manager"): + delattr(parent, "manager") + from aipass.memory.apps.handlers.learnings import manager + + return manager, mock_memory_files + + +# --------------------------------------------------------------------------- +# Fixture: fresh manager per test +# --------------------------------------------------------------------------- + +@pytest.fixture() +def mgr(monkeypatch): + """Yield (manager_module, mock_memory_files) with a fresh import.""" + manager, mock_mf = _import_learnings_manager(monkeypatch) + return manager, mock_mf + + +# =========================================================================== +# TIMESTAMP OPERATIONS +# =========================================================================== + + +class TestParseTimestamp: + """Tests for parse_timestamp().""" + + def test_with_timestamp(self, mgr): + manager, _ = mgr + clean, ts = manager.parse_timestamp("some learning [2026-02-04]") + assert clean == "some learning" + assert ts == "2026-02-04" + + def test_without_timestamp(self, mgr): + manager, _ = mgr + clean, ts = manager.parse_timestamp("no date here") + assert clean == "no date here" + assert ts is None + + def test_timestamp_in_middle_not_matched(self, mgr): + """Only trailing [YYYY-MM-DD] should match.""" + manager, _ = mgr + clean, ts = manager.parse_timestamp("found [2026-01-01] in the middle") + assert ts is None + assert clean == "found [2026-01-01] in the middle" + + def test_with_trailing_whitespace(self, mgr): + manager, _ = mgr + clean, ts = manager.parse_timestamp("value [2026-03-15] ") + assert clean == "value" + assert ts == "2026-03-15" + + def test_empty_string(self, mgr): + manager, _ = mgr + clean, ts = manager.parse_timestamp("") + assert clean == "" + assert ts is None + + +class TestAddTimestamp: + """Tests for add_timestamp().""" + + def test_adds_date_to_plain_value(self, mgr): + manager, _ = mgr + result = manager.add_timestamp("my learning", date="2026-04-03") + assert result == "my learning [2026-04-03]" + + def test_replaces_existing_date(self, mgr): + manager, _ = mgr + result = manager.add_timestamp( + "old learning [2025-01-01]", date="2026-04-03" + ) + assert result == "old learning [2026-04-03]" + + def test_defaults_to_today(self, mgr): + manager, _ = mgr + today = datetime.now().strftime("%Y-%m-%d") + result = manager.add_timestamp("test value") + assert result == f"test value [{today}]" + + def test_explicit_date(self, mgr): + manager, _ = mgr + result = manager.add_timestamp("explicit", date="2030-12-31") + assert result == "explicit [2030-12-31]" + + +class TestGetEntryAge: + """Tests for get_entry_age().""" + + def test_recent_timestamp(self, mgr): + manager, _ = mgr + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + age = manager.get_entry_age(f"learning [{yesterday}]") + assert age == 1 + + def test_today_timestamp(self, mgr): + manager, _ = mgr + today = datetime.now().strftime("%Y-%m-%d") + age = manager.get_entry_age(f"learning [{today}]") + assert age == 0 + + def test_no_timestamp_returns_sentinel(self, mgr): + manager, _ = mgr + age = manager.get_entry_age("no timestamp") + assert age == 999999 + + def test_invalid_timestamp_returns_sentinel(self, mgr): + manager, _ = mgr + age = manager.get_entry_age("bad date [9999-99-99]") + assert age == 999999 + + +# =========================================================================== +# CONFIG OPERATIONS +# =========================================================================== + + +class TestGetMaxLearnings: + """Tests for get_max_learnings().""" + + def test_with_limits_set(self, mgr): + manager, _ = mgr + data = {"document_metadata": {"limits": {"max_learnings": 50}}} + assert manager.get_max_learnings(data) == 50 + + def test_without_limits_returns_default(self, mgr): + manager, _ = mgr + assert manager.get_max_learnings({}) == 100 + + def test_without_max_learnings_key(self, mgr): + manager, _ = mgr + data = {"document_metadata": {"limits": {}}} + assert manager.get_max_learnings(data) == 100 + + +class TestGetMaxRecentlyCompleted: + """Tests for get_max_recently_completed().""" + + def test_with_limits_set(self, mgr): + manager, _ = mgr + data = { + "document_metadata": { + "limits": {"max_recently_completed": 10} + } + } + assert manager.get_max_recently_completed(data) == 10 + + def test_without_limits_returns_default(self, mgr): + manager, _ = mgr + assert manager.get_max_recently_completed({}) == 20 + + +# =========================================================================== +# CORE OPERATIONS -- ensure_timestamps +# =========================================================================== + + +class TestEnsureTimestamps: + """Tests for ensure_timestamps().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "missing.json" + result = manager.ensure_timestamps(missing) + assert result["success"] is False + assert "not found" in result["error"] + + def test_adds_timestamps_to_entries_missing_them(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + sample_data = { + "key_learnings": { + "has_ts": "already stamped [2026-01-01]", + "no_ts": "needs a stamp", + } + } + mock_mf.read_memory_file_data.return_value = sample_data + mock_mf.write_memory_file_simple.return_value = None + + result = manager.ensure_timestamps(fp) + + assert result["success"] is True + assert result["updated"] == 1 + assert result["total"] == 2 + mock_mf.write_memory_file_simple.assert_called_once() + + def test_all_have_timestamps_no_write(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + sample_data = { + "key_learnings": { + "a": "value [2026-01-01]", + "b": "value [2026-02-01]", + } + } + mock_mf.read_memory_file_data.return_value = sample_data + + result = manager.ensure_timestamps(fp) + + assert result["success"] is True + assert result["updated"] == 0 + mock_mf.write_memory_file_simple.assert_not_called() + + def test_no_key_learnings_section(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + mock_mf.read_memory_file_data.return_value = {"sessions": []} + + result = manager.ensure_timestamps(fp) + + assert result["success"] is True + assert result["updated"] == 0 + + def test_returns_error_on_none_data(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + mock_mf.read_memory_file_data.return_value = None + + result = manager.ensure_timestamps(fp) + assert result["success"] is False + + +# =========================================================================== +# CORE OPERATIONS -- enforce_limit +# =========================================================================== + + +class TestEnforceLimit: + """Tests for enforce_limit().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "missing.json" + result = manager.enforce_limit(missing) + assert result["success"] is False + + def test_under_limit_no_removal(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "key_learnings": {"a": "val [2026-01-01]"}, + "document_metadata": {"limits": {"max_learnings": 5}}, + } + mock_mf.read_memory_file_data.return_value = data + + result = manager.enforce_limit(fp) + assert result["success"] is True + assert result["removed"] == 0 + + def test_over_limit_removes_oldest(self, mgr, tmp_path, monkeypatch): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + old_date = (datetime.now() - timedelta(days=300)).strftime("%Y-%m-%d") + new_date = datetime.now().strftime("%Y-%m-%d") + + data = { + "key_learnings": { + "old1": f"old learning [{old_date}]", + "old2": f"ancient [{old_date}]", + "new1": f"recent [{new_date}]", + }, + "document_metadata": {"limits": {"max_learnings": 2}}, + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + # Mock _vectorize_learnings to avoid subprocess + monkeypatch.setattr( + manager, + "_vectorize_learnings", + lambda branch, learnings: {"success": True}, + ) + + result = manager.enforce_limit(fp) + + assert result["success"] is True + assert result["removed"] == 1 + assert result["remaining"] == 2 + assert result["max"] == 2 + mock_mf.write_memory_file_simple.assert_called_once() + + def test_no_learnings_section(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + mock_mf.read_memory_file_data.return_value = {"sessions": []} + + result = manager.enforce_limit(fp) + assert result["success"] is True + assert result["removed"] == 0 + + +# =========================================================================== +# RECENTLY_COMPLETED OPERATIONS +# =========================================================================== + + +class TestEnsureTimestampsCompleted: + """Tests for ensure_timestamps_completed().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "nope.json" + result = manager.ensure_timestamps_completed(missing) + assert result["success"] is False + + def test_adds_timestamps_to_list_entries(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "recently_completed": [ + "Task A [2026-01-01]", + "Task B without stamp", + ] + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + result = manager.ensure_timestamps_completed(fp) + + assert result["success"] is True + assert result["updated"] == 1 + assert result["total"] == 2 + + def test_no_recently_completed(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + mock_mf.read_memory_file_data.return_value = {} + + result = manager.ensure_timestamps_completed(fp) + assert result["success"] is True + assert result["updated"] == 0 + + +class TestEnforceLimitCompleted: + """Tests for enforce_limit_completed().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "missing.json" + result = manager.enforce_limit_completed(missing) + assert result["success"] is False + + def test_under_limit(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "recently_completed": ["Task [2026-01-01]"], + "document_metadata": { + "limits": {"max_recently_completed": 10} + }, + } + mock_mf.read_memory_file_data.return_value = data + + result = manager.enforce_limit_completed(fp) + assert result["success"] is True + assert result["removed"] == 0 + + def test_over_limit_removes_oldest(self, mgr, tmp_path, monkeypatch): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + old_date = (datetime.now() - timedelta(days=200)).strftime("%Y-%m-%d") + new_date = datetime.now().strftime("%Y-%m-%d") + + data = { + "recently_completed": [ + f"Old task [{old_date}]", + f"Another old [{old_date}]", + f"Recent task [{new_date}]", + ], + "document_metadata": { + "limits": {"max_recently_completed": 2} + }, + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + # Mock _vectorize_completed_tasks to avoid subprocess + monkeypatch.setattr( + manager, + "_vectorize_completed_tasks", + lambda branch, tasks: {"success": True}, + ) + + result = manager.enforce_limit_completed(fp) + + assert result["success"] is True + assert result["removed"] == 1 + assert result["remaining"] == 2 + assert result["max"] == 2 + + +# =========================================================================== +# ADD LEARNING +# =========================================================================== + + +class TestAddLearning: + """Tests for add_learning().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "missing.json" + result = manager.add_learning(missing, "key", "value") + assert result["success"] is False + + def test_adds_new_entry(self, mgr, tmp_path, monkeypatch): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "key_learnings": {}, + "document_metadata": {"limits": {"max_learnings": 100}}, + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + # Mock enforce_limit inside add_learning + monkeypatch.setattr( + manager, + "enforce_limit", + lambda fp: {"success": True, "removed": 0}, + ) + + result = manager.add_learning(fp, "test_key", "test value") + + assert result["success"] is True + assert result["action"] == "added" + assert result["key"] == "test_key" + assert "[" in result["value"] # has timestamp + + def test_updates_existing_entry(self, mgr, tmp_path, monkeypatch): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "key_learnings": { + "existing_key": "old value [2025-01-01]" + }, + "document_metadata": {"limits": {"max_learnings": 100}}, + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + monkeypatch.setattr( + manager, + "enforce_limit", + lambda fp: {"success": True, "removed": 0}, + ) + + result = manager.add_learning(fp, "existing_key", "updated value") + + assert result["success"] is True + assert result["action"] == "updated" + + +# =========================================================================== +# UPDATE STATUS COUNTS +# =========================================================================== + + +class TestUpdateStatusCounts: + """Tests for update_status_counts().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "missing.json" + result = manager.update_status_counts(missing) + assert result["success"] is False + + def test_updates_counts_correctly(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "key_learnings": { + "a": "val [2026-01-01]", + "b": "val [2026-01-02]", + "c": "val [2026-01-03]", + }, + "recently_completed": ["task1 [2026-01-01]", "task2 [2026-01-02]"], + "document_metadata": { + "status": { + "current_key_learnings": 0, + "current_recently_completed": 0, + } + }, + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + result = manager.update_status_counts(fp) + + assert result["success"] is True + assert result["current_key_learnings"] == 3 + assert result["current_recently_completed"] == 2 + assert result["changed"] is True + + def test_no_change_skips_write(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "key_learnings": {"a": "val [2026-01-01]"}, + "recently_completed": ["task [2026-01-01]"], + "document_metadata": { + "status": { + "current_key_learnings": 1, + "current_recently_completed": 1, + } + }, + } + mock_mf.read_memory_file_data.return_value = data + + result = manager.update_status_counts(fp) + + assert result["success"] is True + assert result["changed"] is False + mock_mf.write_memory_file_simple.assert_not_called() + + def test_creates_missing_metadata_structure(self, mgr, tmp_path): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + data = { + "key_learnings": {"a": "val [2026-01-01]"}, + "recently_completed": [], + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + result = manager.update_status_counts(fp) + + assert result["success"] is True + assert result["current_key_learnings"] == 1 + assert result["current_recently_completed"] == 0 + + +# =========================================================================== +# BATCH -- process_file +# =========================================================================== + + +class TestProcessFile: + """Tests for process_file().""" + + def test_file_not_found(self, mgr, tmp_path): + manager, _ = mgr + missing = tmp_path / "missing.json" + result = manager.process_file(missing) + assert result["success"] is False + + def test_processes_both_sections(self, mgr, tmp_path, monkeypatch): + manager, mock_mf = mgr + fp = tmp_path / "TEST.local.json" + fp.write_text("{}", encoding="utf-8") + + today = datetime.now().strftime("%Y-%m-%d") + data = { + "key_learnings": { + "a": f"val [{today}]", + }, + "recently_completed": [f"task [{today}]"], + "document_metadata": { + "limits": {"max_learnings": 100, "max_recently_completed": 20}, + "status": { + "current_key_learnings": 0, + "current_recently_completed": 0, + }, + }, + } + mock_mf.read_memory_file_data.return_value = data + mock_mf.write_memory_file_simple.return_value = None + + # Mock vectorization helpers to avoid subprocess calls + monkeypatch.setattr( + manager, + "_vectorize_learnings", + lambda branch, learnings: {"success": True}, + ) + monkeypatch.setattr( + manager, + "_vectorize_completed_tasks", + lambda branch, tasks: {"success": True}, + ) + + result = manager.process_file(fp) + + assert result["success"] is True + assert "key_learnings" in result + assert "recently_completed" in result + assert "status" in result diff --git a/src/aipass/memory/tests/test_storage.py b/src/aipass/memory/tests/test_storage.py new file mode 100644 index 00000000..802a9f24 --- /dev/null +++ b/src/aipass/memory/tests/test_storage.py @@ -0,0 +1,577 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_storage.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for Chroma vector storage handler. + +Covers: + - storage/chroma.py ChromaService class (init, get_collection_name, + store_vectors, get_collection_stats, list_all_collections) + - storage/chroma.py Public API functions (store_vectors, get_collection_stats, + list_all_collections, get_database_info, search_vectors) + - storage/chroma.py Singleton management (get_client, _get_service, global reset) + +All tests use mocks/tmp_path -- no live ChromaDB or filesystem access. +""" + +import sys +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Import helper -- chromadb must be mocked before importing chroma module +# --------------------------------------------------------------------------- + +def _import_chroma(monkeypatch): + """Import chroma module with mocked chromadb dependency.""" + mock_chromadb = MagicMock() + monkeypatch.setitem(sys.modules, "chromadb", mock_chromadb) + + # Clear any cached module so we get a fresh import + sys.modules.pop("aipass.memory.apps.handlers.storage.chroma", None) + parent = sys.modules.get("aipass.memory.apps.handlers.storage") + if parent is not None and hasattr(parent, "chroma"): + delattr(parent, "chroma") + + from aipass.memory.apps.handlers.storage import chroma + + return chroma, mock_chromadb + + +def _reset_globals(chroma): + """Reset module-level singletons between tests.""" + setattr(chroma, "_chroma_service", None) + setattr(chroma, "_local_services", {}) + setattr(chroma, "_chroma_clients", {}) + + +# =========================================================================== +# Tests: ChromaService class +# =========================================================================== + +class TestChromaServiceCollectionName: + """Test ChromaService.get_collection_name.""" + + def test_returns_lowercase_combination(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + result = service.get_collection_name("SEEDGO", "Observations") + + assert result == "seedgo_observations" + + def test_handles_already_lowercase(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + result = service.get_collection_name("cli", "local") + + assert result == "cli_local" + + def test_mixed_case_branch_and_type(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + result = service.get_collection_name("DevPulse", "LOCAL") + + assert result == "devpulse_local" + + +class TestChromaServiceStoreVectors: + """Test ChromaService.store_vectors.""" + + def test_stores_vectors_and_returns_result(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.count.side_effect = [0, 3] # before and after add + + mock_client = MagicMock() + mock_client.get_or_create_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + + embeddings = [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]] + documents = ["doc1", "doc2", "doc3"] + metadatas = [{"k": "v1"}, {"k": "v2"}, {"k": "v3"}] + + result = service.store_vectors("SEEDGO", "observations", embeddings, documents, metadatas) + + assert result["collection"] == "seedgo_observations" + assert result["count"] == 3 + assert result["total_vectors"] == 3 + assert len(result["ids"]) == 3 + mock_collection.add.assert_called_once() + + +class TestChromaServiceGetCollectionStats: + """Test ChromaService.get_collection_stats.""" + + def test_returns_stats_for_existing_collection(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.count.return_value = 42 + + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + result = service.get_collection_stats("seedgo", "observations") + + assert result["exists"] is True + assert result["vector_count"] == 42 + assert result["collection"] == "seedgo_observations" + + def test_returns_not_exists_when_collection_missing(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_client.get_collection.side_effect = Exception("Collection not found") + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + result = service.get_collection_stats("missing", "local") + + assert result["exists"] is False + assert result["vector_count"] == 0 + + +class TestChromaServiceListCollections: + """Test ChromaService.list_all_collections.""" + + def test_returns_collection_names(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_col1 = MagicMock() + mock_col1.name = "seedgo_observations" + mock_col2 = MagicMock() + mock_col2.name = "cli_local" + + mock_client = MagicMock() + mock_client.list_collections.return_value = [mock_col1, mock_col2] + mock_chromadb.PersistentClient.return_value = mock_client + + service = chroma.ChromaService(db_path=tmp_path / ".chroma") + result = service.list_all_collections() + + assert result == ["seedgo_observations", "cli_local"] + + +# =========================================================================== +# Tests: Public API — store_vectors +# =========================================================================== + +class TestPublicStoreVectors: + """Test public store_vectors function.""" + + def test_empty_embeddings_returns_success_zero_count(self, monkeypatch): + chroma, _ = _import_chroma(monkeypatch) + _reset_globals(chroma) + + result = chroma.store_vectors("SEEDGO", "observations", [], [], []) + + assert result["success"] is True + assert result["count"] == 0 + + def test_length_mismatch_returns_failure(self, monkeypatch): + chroma, _ = _import_chroma(monkeypatch) + _reset_globals(chroma) + + result = chroma.store_vectors( + "SEEDGO", "observations", + embeddings=[[0.1, 0.2]], + documents=["doc1", "doc2"], + metadatas=[{"k": "v"}], + ) + + assert result["success"] is False + assert "Length mismatch" in result["error"] + + def test_successful_store_delegates_to_service(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.count.side_effect = [0, 2] + + mock_client = MagicMock() + mock_client.get_or_create_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + result = chroma.store_vectors( + "SEEDGO", "observations", + embeddings=[[0.1, 0.2], [0.3, 0.4]], + documents=["doc1", "doc2"], + metadatas=[{"branch": "SEEDGO"}, {"branch": "SEEDGO"}], + db_path=tmp_path / ".chroma", + ) + + assert result["success"] is True + assert result["count"] == 2 + assert result["collection"] == "seedgo_observations" + + def test_string_db_path_converts_to_path(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.count.side_effect = [0, 1] + + mock_client = MagicMock() + mock_client.get_or_create_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + str_path = str(tmp_path / ".chroma") + result = chroma.store_vectors( + "CLI", "local", + embeddings=[[0.1]], + documents=["doc1"], + metadatas=[{"branch": "CLI"}], + db_path=str_path, + ) + + assert result["success"] is True + # Verify PersistentClient was called with a string (Path converted internally) + mock_chromadb.PersistentClient.assert_called() + + def test_service_exception_returns_failure(self, monkeypatch): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_chromadb.PersistentClient.side_effect = RuntimeError("DB error") + + result = chroma.store_vectors( + "SEEDGO", "observations", + embeddings=[[0.1]], + documents=["doc1"], + metadatas=[{"k": "v"}], + ) + + assert result["success"] is False + assert "Storage failed" in result["error"] + + +# =========================================================================== +# Tests: Public API — get_collection_stats +# =========================================================================== + +class TestPublicGetCollectionStats: + """Test public get_collection_stats function.""" + + def test_success_returns_stats(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.count.return_value = 10 + + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + # Pre-create the global service so _get_service uses our mock + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=tmp_path / ".chroma")) + + result = chroma.get_collection_stats("seedgo", "observations") + + assert result["success"] is True + assert result["exists"] is True + assert result["vector_count"] == 10 + + def test_nonexistent_collection_still_succeeds(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_client.get_collection.side_effect = Exception("not found") + mock_chromadb.PersistentClient.return_value = mock_client + + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=tmp_path / ".chroma")) + + result = chroma.get_collection_stats("missing", "local") + + assert result["success"] is True + assert result["exists"] is False + assert result["vector_count"] == 0 + + +# =========================================================================== +# Tests: Public API — list_all_collections +# =========================================================================== + +class TestPublicListAllCollections: + """Test public list_all_collections function.""" + + def test_returns_collection_list(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_col = MagicMock() + mock_col.name = "seedgo_observations" + + mock_client = MagicMock() + mock_client.list_collections.return_value = [mock_col] + mock_chromadb.PersistentClient.return_value = mock_client + + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=tmp_path / ".chroma")) + + result = chroma.list_all_collections() + + assert result["success"] is True + assert result["collections"] == ["seedgo_observations"] + assert result["count"] == 1 + + +# =========================================================================== +# Tests: Public API — get_database_info +# =========================================================================== + +class TestPublicGetDatabaseInfo: + """Test public get_database_info function.""" + + def test_returns_db_info(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_col1 = MagicMock() + mock_col1.name = "seedgo_observations" + mock_col2 = MagicMock() + mock_col2.name = "cli_local" + + mock_client = MagicMock() + mock_client.list_collections.return_value = [mock_col1, mock_col2] + mock_chromadb.PersistentClient.return_value = mock_client + + db_path = tmp_path / ".chroma" + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) + + result = chroma.get_database_info() + + assert result["success"] is True + assert result["db_path"] == str(db_path) + assert result["collections_count"] == 2 + assert result["collections"] == ["seedgo_observations", "cli_local"] + + +# =========================================================================== +# Tests: Public API — search_vectors +# =========================================================================== + +class TestPublicSearchVectors: + """Test public search_vectors function.""" + + def test_search_specific_branch_and_type(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.query.return_value = { + "documents": [["doc1", "doc2"]], + "metadatas": [[{"branch": "SEEDGO"}, {"branch": "SEEDGO"}]], + "distances": [[0.1, 0.3]], + "ids": [["id1", "id2"]], + } + + mock_client = MagicMock() + mock_client.get_or_create_collection.return_value = mock_collection + mock_client.get_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + db_path = tmp_path / ".chroma" + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) + + query_emb = [0.1] * 384 + result = chroma.search_vectors(query_emb, branch="SEEDGO", memory_type="observations") + + assert result["success"] is True + assert result["collections_searched"] == 1 + assert result["total_results"] == 2 + assert result["results"][0]["document"] == "doc1" + assert result["results"][0]["distance"] == 0.1 + + def test_global_search_no_branch_filter(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_col1 = MagicMock() + mock_col1.name = "seedgo_observations" + mock_col2 = MagicMock() + mock_col2.name = "cli_local" + + mock_collection = MagicMock() + mock_collection.query.return_value = { + "documents": [["result_doc"]], + "metadatas": [[{"branch": "test"}]], + "distances": [[0.2]], + "ids": [["id_global"]], + } + + mock_client = MagicMock() + mock_client.list_collections.return_value = [mock_col1, mock_col2] + mock_client.get_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + db_path = tmp_path / ".chroma" + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) + + result = chroma.search_vectors([0.1] * 384) + + assert result["success"] is True + assert result["collections_searched"] == 2 + # Two collections searched, each returns 1 result + assert result["total_results"] == 2 + + def test_search_with_no_matching_collections(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_client.list_collections.return_value = [] + mock_chromadb.PersistentClient.return_value = mock_client + + db_path = tmp_path / ".chroma" + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) + + result = chroma.search_vectors([0.1] * 384) + + assert result["success"] is True + assert result["results"] == [] + assert "No matching collections" in result.get("message", "") + + def test_search_with_string_db_path(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.query.return_value = { + "documents": [["found"]], + "metadatas": [[{"k": "v"}]], + "distances": [[0.05]], + "ids": [["id_str"]], + } + + mock_client = MagicMock() + mock_client.get_or_create_collection.return_value = mock_collection + mock_client.get_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + str_path = str(tmp_path / ".chroma") + result = chroma.search_vectors( + [0.1] * 384, + branch="CLI", + memory_type="local", + db_path=str_path, + ) + + assert result["success"] is True + assert len(result["results"]) == 1 + + def test_search_handles_collection_error_gracefully(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_col = MagicMock() + mock_col.name = "broken_collection" + + mock_client = MagicMock() + mock_client.list_collections.return_value = [mock_col] + mock_client.get_collection.side_effect = Exception("Corrupt collection") + mock_chromadb.PersistentClient.return_value = mock_client + + db_path = tmp_path / ".chroma" + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) + + result = chroma.search_vectors([0.1] * 384) + + assert result["success"] is True + assert result["total_results"] == 0 + + def test_search_results_sorted_by_distance(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_collection = MagicMock() + mock_collection.query.return_value = { + "documents": [["far_doc", "near_doc", "mid_doc"]], + "metadatas": [[{"k": "1"}, {"k": "2"}, {"k": "3"}]], + "distances": [[0.9, 0.1, 0.5]], + "ids": [["id_far", "id_near", "id_mid"]], + } + + mock_client = MagicMock() + mock_client.get_collection.return_value = mock_collection + mock_chromadb.PersistentClient.return_value = mock_client + + db_path = tmp_path / ".chroma" + setattr(chroma, "_chroma_service", chroma.ChromaService(db_path=db_path)) + + result = chroma.search_vectors( + [0.1] * 384, branch="test", memory_type="local", + ) + + assert result["success"] is True + distances = [r["distance"] for r in result["results"]] + assert distances == sorted(distances) + + +# =========================================================================== +# Tests: Singleton / get_client +# =========================================================================== + +class TestGetClient: + """Test get_client singleton behaviour.""" + + def test_caches_client_per_path(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client = MagicMock() + mock_chromadb.PersistentClient.return_value = mock_client + + path = tmp_path / ".chroma" + client1 = chroma.get_client(path) + client2 = chroma.get_client(path) + + assert client1 is client2 + # PersistentClient should only be called once for same path + assert mock_chromadb.PersistentClient.call_count == 1 + + def test_different_paths_get_different_clients(self, monkeypatch, tmp_path): + chroma, mock_chromadb = _import_chroma(monkeypatch) + _reset_globals(chroma) + + mock_client_a = MagicMock() + mock_client_b = MagicMock() + mock_chromadb.PersistentClient.side_effect = [mock_client_a, mock_client_b] + + path_a = tmp_path / ".chroma_a" + path_b = tmp_path / ".chroma_b" + + client_a = chroma.get_client(path_a) + client_b = chroma.get_client(path_b) + + assert client_a is not client_b + assert mock_chromadb.PersistentClient.call_count == 2 diff --git a/src/aipass/memory/tests/test_vector.py b/src/aipass/memory/tests/test_vector.py new file mode 100644 index 00000000..88e85e9c --- /dev/null +++ b/src/aipass/memory/tests/test_vector.py @@ -0,0 +1,359 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_vector.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Tests for vector embedding handler. + +Covers: + - vector/embedder.py EmbeddingService class (init, encode_batch with + pre-sort by length and order restoration, GPU cleanup path) + - vector/embedder.py Public API functions (encode_batch, encode_memories, + get_model_info) + - vector/embedder.py Singleton management (_get_service, global reset) + +All tests use mocks/tmp_path -- no live sentence-transformers, torch, or GPU access. +""" + +import sys +from typing import Any +from unittest.mock import MagicMock + +import numpy as np + + +# --------------------------------------------------------------------------- +# Import helper -- torch and sentence_transformers must be mocked +# --------------------------------------------------------------------------- + +def _import_embedder(monkeypatch): + """Import embedder module with mocked ML dependencies. + + Returns: + Tuple of (embedder module, dict of mock objects) + """ + mock_torch = MagicMock() + mock_torch.cuda.is_available.return_value = False + monkeypatch.setitem(sys.modules, "torch", mock_torch) + + mock_st = MagicMock() + mock_model = MagicMock() + mock_st.SentenceTransformer.return_value = mock_model + monkeypatch.setitem(sys.modules, "sentence_transformers", mock_st) + + # Clear cached module for fresh import + sys.modules.pop("aipass.memory.apps.handlers.vector.embedder", None) + parent = sys.modules.get("aipass.memory.apps.handlers.vector") + if parent is not None and hasattr(parent, "embedder"): + delattr(parent, "embedder") + + from aipass.memory.apps.handlers.vector import embedder + + return embedder, { + "torch": mock_torch, + "st": mock_st, + "model": mock_model, + } + + +def _reset_globals(embedder) -> None: + """Reset module-level singleton between tests.""" + setattr(embedder, "_embedding_service", None) + + +# =========================================================================== +# Tests: Public API -- encode_batch +# =========================================================================== + +class TestPublicEncodeBatch: + """Test public encode_batch function.""" + + def test_empty_list_returns_success_zero_count(self, monkeypatch): + embedder, _ = _import_embedder(monkeypatch) + _reset_globals(embedder) + + result = embedder.encode_batch([]) + + assert result["success"] is True + assert result["count"] == 0 + assert result["embeddings"] == [] + + def test_successful_encoding(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + fake_embeddings = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) + mocks["model"].encode.return_value = fake_embeddings + + result = embedder.encode_batch(["hello world", "test text"]) + + assert result["success"] is True + assert result["count"] == 2 + assert result["dimension"] == 384 + + def test_service_failure_returns_error(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["model"].encode.side_effect = RuntimeError("CUDA out of memory") + + result = embedder.encode_batch(["some text"]) + + assert result["success"] is False + assert "Encoding failed" in result["error"] + + def test_service_init_failure_returns_error(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["st"].SentenceTransformer.side_effect = RuntimeError("Model not found") + + result = embedder.encode_batch(["some text"]) + + assert result["success"] is False + assert "failed" in result["error"].lower() + + +# =========================================================================== +# Tests: Public API -- encode_memories +# =========================================================================== + +class TestPublicEncodeMemories: + """Test public encode_memories function.""" + + def test_empty_list_returns_success(self, monkeypatch): + embedder, _ = _import_embedder(monkeypatch) + _reset_globals(embedder) + + result = embedder.encode_memories([]) + + assert result["success"] is True + assert result["count"] == 0 + assert result["embeddings"] == [] + assert result["memories"] == [] + + def test_extracts_content_field(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + fake_embeddings = np.array([[0.1, 0.2]]) + mocks["model"].encode.return_value = fake_embeddings + + memories = [{"content": "Important observation", "timestamp": "2026-01-01"}] + result = embedder.encode_memories(memories) + + assert result["success"] is True + assert result["count"] == 1 + assert result["memories"] == memories + # Verify the model was called with extracted text + call_args = mocks["model"].encode.call_args + texts_passed = call_args[0][0] + assert texts_passed == ["Important observation"] + + def test_extracts_text_field(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + fake_embeddings = np.array([[0.1, 0.2]]) + mocks["model"].encode.return_value = fake_embeddings + + memories = [{"text": "Session summary", "date": "2026-02-01"}] + result = embedder.encode_memories(memories) + + assert result["success"] is True + assert result["count"] == 1 + call_args = mocks["model"].encode.call_args + texts_passed = call_args[0][0] + assert texts_passed == ["Session summary"] + + def test_falls_back_to_str_representation(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + fake_embeddings = np.array([[0.1, 0.2]]) + mocks["model"].encode.return_value = fake_embeddings + + memories = [{"arbitrary_key": "value123", "number": 42}] + result = embedder.encode_memories(memories) + + assert result["success"] is True + assert result["count"] == 1 + # The fallback is str(memory) which includes the full dict repr + call_args = mocks["model"].encode.call_args + texts_passed = call_args[0][0] + assert "arbitrary_key" in texts_passed[0] + + def test_encoding_failure_propagates(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["model"].encode.side_effect = RuntimeError("Encoding crashed") + + memories = [{"content": "test memory"}] + result = embedder.encode_memories(memories) + + assert result["success"] is False + + def test_multiple_memories_mixed_fields(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + fake_embeddings = np.array([[0.1], [0.2], [0.3]]) + mocks["model"].encode.return_value = fake_embeddings + + memories = [ + {"content": "first"}, + {"text": "second"}, + {"other": "third"}, + ] + result = embedder.encode_memories(memories) + + assert result["success"] is True + assert result["count"] == 3 + assert result["memories"] is memories + + call_args = mocks["model"].encode.call_args + texts_passed = call_args[0][0] + assert texts_passed[0] == "first" + assert texts_passed[1] == "second" + # Third falls back to str() + assert "third" in texts_passed[2] + + +# =========================================================================== +# Tests: Public API -- get_model_info +# =========================================================================== + +class TestPublicGetModelInfo: + """Test public get_model_info function.""" + + def test_returns_model_metadata(self, monkeypatch): + embedder, _ = _import_embedder(monkeypatch) + _reset_globals(embedder) + + result = embedder.get_model_info() + + assert result["success"] is True + assert result["model_name"] == "all-MiniLM-L6-v2" + assert result["dimension"] == 384 + assert result["batch_size"] == 16 # CPU batch size (GPU is mocked off) + assert result["gpu_enabled"] is False + + def test_service_init_failure_returns_error(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["st"].SentenceTransformer.side_effect = ImportError("no model") + + result = embedder.get_model_info() + + assert result["success"] is False + assert "error" in result + + +# =========================================================================== +# Tests: EmbeddingService class -- encode_batch internals +# =========================================================================== + +class TestEmbeddingServiceEncodeBatch: + """Test EmbeddingService.encode_batch pre-sort and order restoration.""" + + def test_presorts_by_length_and_restores_order(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + # Track what texts the model receives (should be sorted by length) + received_texts: list[Any] = [] + + def fake_encode(texts, **kwargs): + received_texts.append(list(texts)) + # Return embeddings matching the sorted input length + return np.array([[float(i)] * 3 for i in range(len(texts))]) + + mocks["model"].encode.side_effect = fake_encode + + service = embedder.EmbeddingService() + texts = ["long text here", "ab", "medium text"] + result = service.encode_batch(texts) + + # Model should receive texts sorted by length + assert received_texts[0] == ["ab", "medium text", "long text here"] + + # But returned embeddings should be in original order + assert result["count"] == 3 + # Index 0 was "long text here" (sorted position 2) -> embedding [2,2,2] + # Index 1 was "ab" (sorted position 0) -> embedding [0,0,0] + # Index 2 was "medium text" (sorted position 1) -> embedding [1,1,1] + embs = result["embeddings"] + np.testing.assert_array_equal(embs[0], [2.0, 2.0, 2.0]) + np.testing.assert_array_equal(embs[1], [0.0, 0.0, 0.0]) + np.testing.assert_array_equal(embs[2], [1.0, 1.0, 1.0]) + + def test_empty_texts_returns_empty(self, monkeypatch): + embedder, _ = _import_embedder(monkeypatch) + _reset_globals(embedder) + + service = embedder.EmbeddingService() + result = service.encode_batch([]) + + assert result["count"] == 0 + assert result["embeddings"] == [] + assert result["dimension"] == 384 + + def test_single_text_works(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["model"].encode.return_value = np.array([[0.5, 0.6]]) + + service = embedder.EmbeddingService() + result = service.encode_batch(["only one"]) + + assert result["count"] == 1 + np.testing.assert_array_equal(result["embeddings"][0], [0.5, 0.6]) + + +# =========================================================================== +# Tests: EmbeddingService -- GPU path +# =========================================================================== + +class TestEmbeddingServiceGPU: + """Test EmbeddingService GPU detection and cleanup.""" + + def test_gpu_enabled_sets_larger_batch_size(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["torch"].cuda.is_available.return_value = True + + service = embedder.EmbeddingService() + + assert service.use_gpu is True + assert service.batch_size == 64 + + def test_gpu_cache_cleared_after_encode(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["torch"].cuda.is_available.return_value = True + mocks["model"].encode.return_value = np.array([[0.1, 0.2]]) + + service = embedder.EmbeddingService() + service.encode_batch(["test text"]) + + mocks["torch"].cuda.empty_cache.assert_called_once() + + def test_cpu_does_not_clear_gpu_cache(self, monkeypatch): + embedder, mocks = _import_embedder(monkeypatch) + _reset_globals(embedder) + + mocks["torch"].cuda.is_available.return_value = False + mocks["model"].encode.return_value = np.array([[0.1, 0.2]]) + + service = embedder.EmbeddingService() + service.encode_batch(["test text"]) + + mocks["torch"].cuda.empty_cache.assert_not_called() diff --git a/src/aipass/prax/tests/test_central.py b/src/aipass/prax/tests/test_central.py new file mode 100644 index 00000000..bc333beb --- /dev/null +++ b/src/aipass/prax/tests/test_central.py @@ -0,0 +1,248 @@ +# =================== AIPass ==================== +# Name: test_central.py +# Description: Tests for central file reader handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for apps/handlers/central/reader.py -- read_all_centrals(). + +Covers: valid central files, empty directory, missing directory, +malformed JSON, mixed valid/invalid files, service name derivation. +""" + +import json +import sys +import importlib +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +# ============================================= +# HELPERS +# ============================================= + +def _fresh_import_reader(monkeypatch, tmp_path): + """Import reader module with a fresh state, patching _find_repo_root to tmp_path. + + Evicts cached modules so the module-level logger and json_handler + pick up the mocked sys.modules entries from conftest. + """ + for key in list(sys.modules.keys()): + if "aipass.prax.apps.handlers.central" in key: + sys.modules.pop(key, None) + + import aipass.prax.apps.handlers.central.reader as reader_mod + + # Patch _find_repo_root so it returns tmp_path (our fake repo root) + monkeypatch.setattr(reader_mod, "_find_repo_root", lambda: tmp_path) + + return reader_mod + + +# ============================================= +# TESTS: read_all_centrals +# ============================================= + +class TestReadAllCentrals: + """Tests for read_all_centrals().""" + + def test_returns_dict(self, mock_prax_infrastructure, monkeypatch, tmp_path): + reader = _fresh_import_reader(monkeypatch, tmp_path) + result = reader.read_all_centrals() + assert isinstance(result, dict) + + def test_empty_dict_when_dir_missing(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """No .ai_central directory should return empty dict.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + # Do NOT create .ai_central + result = reader.read_all_centrals() + assert result == {} + + def test_empty_dict_when_dir_empty(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Empty .ai_central directory should return empty dict.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + (tmp_path / ".ai_central").mkdir() + result = reader.read_all_centrals() + assert result == {} + + def test_reads_single_central_file(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """A single valid .central.json should be returned keyed by lowered service name.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + payload = {"status": "active", "version": "1.0.0"} + (central_dir / "AI_MAIL.central.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert "ai_mail" in result + assert result["ai_mail"] == payload + + def test_reads_multiple_central_files(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Multiple central files should all appear in the result.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + services = { + "AI_MAIL": {"type": "mail", "count": 5}, + "PLANS": {"type": "planner", "active": True}, + "DEVPULSE": {"type": "monitor", "uptime": 99.9}, + } + for name, data in services.items(): + (central_dir / f"{name}.central.json").write_text( + json.dumps(data), encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert len(result) == 3 + assert result["ai_mail"] == services["AI_MAIL"] + assert result["plans"] == services["PLANS"] + assert result["devpulse"] == services["DEVPULSE"] + + def test_service_name_lowercased(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Service name key should be the filename stem lowercased.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + (central_dir / "MyService.central.json").write_text( + json.dumps({"ok": True}), encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert "myservice" in result + assert "MyService" not in result + + def test_skips_malformed_json(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Malformed JSON file should be skipped, not crash.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + (central_dir / "BAD.central.json").write_text( + "{not valid json!!", encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert "bad" not in result + assert result == {} + + def test_malformed_file_does_not_block_valid_files( + self, mock_prax_infrastructure, monkeypatch, tmp_path + ): + """A broken file should not prevent other valid files from loading.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + good_data = {"healthy": True} + (central_dir / "GOOD.central.json").write_text( + json.dumps(good_data), encoding="utf-8" + ) + (central_dir / "BAD.central.json").write_text( + "<<>>", encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert len(result) == 1 + assert result["good"] == good_data + assert "bad" not in result + + def test_ignores_non_central_json_files(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Files not matching *.central.json pattern should be ignored.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + # A valid central file + (central_dir / "VALID.central.json").write_text( + json.dumps({"ok": True}), encoding="utf-8" + ) + # Files that should NOT be picked up + (central_dir / "notes.txt").write_text("just a note", encoding="utf-8") + (central_dir / "config.json").write_text( + json.dumps({"nope": True}), encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert len(result) == 1 + assert "valid" in result + + def test_logs_warning_on_malformed_json(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should call logger.warning when a file has bad JSON.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + (central_dir / "BROKEN.central.json").write_text( + "not json", encoding="utf-8" + ) + + reader.read_all_centrals() + reader.logger.warning.assert_called() # type: ignore[union-attr] + + def test_calls_json_handler_log_operation(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should log the operation via json_handler after reading.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + (central_dir / "SVC.central.json").write_text( + json.dumps({"ok": True}), encoding="utf-8" + ) + + reader.read_all_centrals() + reader.json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] + "central_data_read", {"services_found": 1} + ) + + def test_empty_json_object_is_valid(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """An empty JSON object {} is still valid and should be included.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + (central_dir / "EMPTY.central.json").write_text( + json.dumps({}), encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert "empty" in result + assert result["empty"] == {} + + def test_nested_json_structure_preserved(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Deeply nested JSON data should be preserved as-is.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + central_dir = tmp_path / ".ai_central" + central_dir.mkdir() + + nested = { + "level1": { + "level2": { + "items": [1, 2, 3], + "flag": True + } + } + } + (central_dir / "NESTED.central.json").write_text( + json.dumps(nested), encoding="utf-8" + ) + + result = reader.read_all_centrals() + assert result["nested"] == nested + assert result["nested"]["level1"]["level2"]["items"] == [1, 2, 3] + + def test_no_json_handler_call_when_dir_missing( + self, mock_prax_infrastructure, monkeypatch, tmp_path + ): + """When directory is missing, should return early without calling json_handler.""" + reader = _fresh_import_reader(monkeypatch, tmp_path) + # No .ai_central directory + reader.read_all_centrals() + reader.json_handler.log_operation.assert_not_called() # type: ignore[union-attr] diff --git a/src/aipass/prax/tests/test_log_watcher.py b/src/aipass/prax/tests/test_log_watcher.py new file mode 100644 index 00000000..528dadbb --- /dev/null +++ b/src/aipass/prax/tests/test_log_watcher.py @@ -0,0 +1,449 @@ +# =================== AIPass ==================== +# Name: test_log_watcher.py +# Description: Tests for log file monitoring handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for apps/handlers/monitoring/log_watcher.py + +Covers: +- LogFileWatcher._detect_log_level() -- level detection from markers +- LogFileWatcher._extract_command_info() -- command pattern matching +- LogFileWatcher._parse_log_message() -- pipe-delimited parsing +- start_log_watcher / stop_log_watcher / is_log_watcher_active +- initialize_positions() -- seek-to-end on startup +""" + +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _RealFSHandler: + """Stub base so LogFileWatcher subclass methods are not swallowed by MagicMock.""" + pass + + +def _import_log_watcher() -> ModuleType: + """Import log_watcher with all heavy deps mocked.""" + mock_observer_cls = MagicMock() + mock_observer_instance = MagicMock() + mock_observer_cls.return_value = mock_observer_instance + + mock_watchdog_observer = MagicMock() + mock_watchdog_observer.Observer = mock_observer_cls + + # Provide a real base class for FileSystemEventHandler + mock_watchdog_events = MagicMock() + mock_watchdog_events.FileSystemEventHandler = _RealFSHandler + + mock_config = MagicMock() + mock_config.get_system_logs_dir.return_value = Path("/fake/logs/system") + + mock_branch_detector = MagicMock() + mock_branch_detector.detect_branch_from_log.return_value = "PRAX" + + mock_event_queue_mod = MagicMock() + + mock_trigger_mod = MagicMock() + + with patch.dict(sys.modules, { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.monitoring.event_queue": mock_event_queue_mod, + "aipass.prax.apps.handlers.monitoring.branch_detector": mock_branch_detector, + "aipass.trigger": MagicMock(), + "aipass.trigger.apps": MagicMock(), + "aipass.trigger.apps.modules": MagicMock(), + "aipass.trigger.apps.modules.core": mock_trigger_mod, + }): + import importlib + if "aipass.prax.apps.handlers.monitoring.log_watcher" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.monitoring.log_watcher"]) + else: + mod = importlib.import_module("aipass.prax.apps.handlers.monitoring.log_watcher") + + return mod + + +def _make_watcher(mod: ModuleType): + """Create a LogFileWatcher with a mock event queue.""" + mock_queue = MagicMock() + watcher = mod.LogFileWatcher(mock_queue) + return watcher, mock_queue + + +# ============================================================================ +# _detect_log_level tests +# ============================================================================ + +class TestDetectLogLevel: + """Test log level detection from raw log lines.""" + + def test_error_markers(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + for line in [ + "2025-11-23 10:00:00 - ERROR - Something broke", + "[PRAX] 10:00:00 | module | ERROR | bad thing", + "[ERROR] connection refused", + "2025-11-23 - CRITICAL - fatal failure", + " CRITICAL shutdown imminent", + "[CRITICAL] out of memory", + ]: + assert watcher._detect_log_level(line) == "error", f"Expected 'error' for: {line}" + + def test_warning_markers(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + for line in [ + "2025-11-23 - WARNING - disk space low", + " WARNING deprecated function used", + "[WARNING] slow query detected", + ]: + assert watcher._detect_log_level(line) == "warning", f"Expected 'warning' for: {line}" + + def test_debug_markers(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + for line in [ + "2025-11-23 - DEBUG - entering function", + " DEBUG variable x = 42", + "[DEBUG] cache miss", + ]: + assert watcher._detect_log_level(line) == "debug", f"Expected 'debug' for: {line}" + + def test_info_default(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + for line in [ + "2025-11-23 - INFO - server started", + "Just a plain log line with no markers", + "", + ]: + assert watcher._detect_log_level(line) == "info", f"Expected 'info' for: {line!r}" + + +# ============================================================================ +# _extract_command_info tests +# ============================================================================ + +class TestExtractCommandInfo: + """Test command pattern extraction from log lines.""" + + def test_drone_started_args(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[drone] Drone started with args: ['close', 'plan', '0098']" + result = watcher._extract_command_info(line) + assert result is not None + assert "drone" in result["command"] + assert "close" in result["command"] + + def test_flow_creating_plan(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[FLOW] Creating new flow plan" + result = watcher._extract_command_info(line) + assert result is not None + assert "flow create plan" in result["command"] + + def test_flow_closing_plan(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[FLOW] Closing FPLAN-0164" + result = watcher._extract_command_info(line) + assert result is not None + assert "flow close plan" in result["command"] + assert "0164" in result["command"] + + def test_flow_opening_plan(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[FLOW] Opening FPLAN-0098" + result = watcher._extract_command_info(line) + assert result is not None + assert "flow open plan" in result["command"] + assert "0098" in result["command"] + + def test_seedgo_audit(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[seedgo] Auditing PRAX branch" + result = watcher._extract_command_info(line) + assert result is not None + assert "seedgo audit" in result["command"] + assert result["target"] == "PRAX" + + def test_ai_mail_send(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[ai_mail] Sending message to @flow" + result = watcher._extract_command_info(line) + assert result is not None + assert "ai_mail send" in result["command"] + assert result["target"] == "FLOW" + + def test_ai_mail_inbox(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[ai_mail] checking inbox" + result = watcher._extract_command_info(line) + assert result is not None + assert "ai_mail inbox" in result["command"] + + def test_prax_monitor(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[prax] Starting monitor session" + result = watcher._extract_command_info(line) + assert result is not None + assert "prax monitor" in result["command"] + + def test_prax_status(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[prax] Running status check" + result = watcher._extract_command_info(line) + assert result is not None + assert "prax status" in result["command"] + + def test_backup_snapshot(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[backup] Starting snapshot" + result = watcher._extract_command_info(line) + assert result is not None + assert "backup snapshot" in result["command"] + + def test_caller_attribution_routing(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "Routing @flow [CALLER:PRAX] \u2192 create ['.', 'Subject']" + result = watcher._extract_command_info(line) + assert result is not None + assert result["caller"] == "PRAX" + assert result["target"] == "FLOW" + + def test_executing_command_with_caller(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "Executing command [CALLER:SEEDGO]: /path/to/aipass/prax/apps/status.py audit @prax" + result = watcher._extract_command_info(line) + assert result is not None + assert result["caller"] == "SEEDGO" + + def test_non_command_line_returns_none(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "2025-11-23 - INFO - Just a normal log line" + result = watcher._extract_command_info(line) + assert result is None + + def test_memory_rollover(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[memory] Starting rollover process" + result = watcher._extract_command_info(line) + assert result is not None + assert "memory rollover" in result["command"] + + def test_spawn_create_branch(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[spawn] Creating branch NEWMOD" + result = watcher._extract_command_info(line) + assert result is not None + assert "spawn create branch" in result["command"] + assert result["target"] == "NEWMOD" + + def test_trigger_fired(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[trigger] Event fired: module_discovered" + result = watcher._extract_command_info(line) + assert result is not None + assert "trigger fire" in result["command"] + + +# ============================================================================ +# _parse_log_message tests +# ============================================================================ + +class TestParseLogMessage: + """Test pipe-delimited log line parsing.""" + + def test_four_part_format(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[PRAX] 2025-11-23 10:00:00 | prax.status | INFO | Server started successfully" + result = watcher._parse_log_message(line) + assert result == "Server started successfully" + + def test_multi_pipe_message(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "[PRAX] 2025-11-23 | source | WARNING | First part | second part" + result = watcher._parse_log_message(line) + assert result == "First part | second part" + + def test_two_part_format(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "source | The actual message" + result = watcher._parse_log_message(line) + assert result == "The actual message" + + def test_no_pipe_fallback(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = "Just a plain line with no pipes" + result = watcher._parse_log_message(line) + assert result == "Just a plain line with no pipes" + + def test_strips_whitespace(self): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + line = " [PRAX] 2025-11-23 | source | INFO | message with spaces " + result = watcher._parse_log_message(line) + assert result == "message with spaces" + + +# ============================================================================ +# start/stop/is_active lifecycle tests +# ============================================================================ + +class TestLogWatcherLifecycle: + """Test start_log_watcher, stop_log_watcher, is_log_watcher_active.""" + + def test_start_creates_and_starts_observer(self): + mod = _import_log_watcher() + mock_queue = MagicMock() + mock_observer = MagicMock() + + setattr(mod, "_log_observer", None) + + with patch.object(mod, "WatchdogObserver", return_value=mock_observer): + with patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs")): + result = mod.start_log_watcher(mock_queue) + + assert result is mock_observer + mock_observer.schedule.assert_called_once() + mock_observer.start.assert_called_once() + + def test_start_with_polling_mode(self): + mod = _import_log_watcher() + mock_queue = MagicMock() + mock_observer = MagicMock() + mock_polling_cls = MagicMock(return_value=mock_observer) + + setattr(mod, "_log_observer", None) + + with patch.dict(sys.modules, { + "watchdog.observers.polling": MagicMock(PollingObserver=mock_polling_cls), + }): + with patch.object(mod, "get_system_logs_dir", return_value=Path("/fake/logs")): + result = mod.start_log_watcher(mock_queue, use_polling=True) + + assert result is mock_observer + mock_observer.start.assert_called_once() + + def test_stop_stops_and_clears(self): + mod = _import_log_watcher() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = True + setattr(mod, "_log_observer", mock_obs) + + mod.stop_log_watcher() + + mock_obs.stop.assert_called_once() + mock_obs.join.assert_called_once_with(timeout=5.0) + assert getattr(mod, "_log_observer") is None + + def test_stop_noop_when_not_running(self): + mod = _import_log_watcher() + setattr(mod, "_log_observer", None) + # Should not raise + mod.stop_log_watcher() + + def test_is_active_true_when_alive(self): + mod = _import_log_watcher() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = True + setattr(mod, "_log_observer", mock_obs) + assert mod.is_log_watcher_active() is True + + def test_is_active_false_when_none(self): + mod = _import_log_watcher() + setattr(mod, "_log_observer", None) + assert mod.is_log_watcher_active() is False + + def test_is_active_false_when_dead(self): + mod = _import_log_watcher() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = False + setattr(mod, "_log_observer", mock_obs) + assert mod.is_log_watcher_active() is False + + +# ============================================================================ +# initialize_positions tests +# ============================================================================ + +class TestInitializePositions: + """Test seek-to-end initialization.""" + + def test_initializes_to_end_of_existing_files(self, tmp_path): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + # Create fake log files with known content + log1 = tmp_path / "prax_flow.log" + log1.write_text("line 1\nline 2\nline 3\n", encoding="utf-8") + log2 = tmp_path / "prax_drone.log" + log2.write_text("short\n", encoding="utf-8") + + with patch.object(mod, "get_system_logs_dir", return_value=tmp_path): + watcher.initialize_positions() + + # Positions should be set to file sizes + assert watcher.log_positions[str(log1)] == log1.stat().st_size + assert watcher.log_positions[str(log2)] == log2.stat().st_size + + def test_handles_nonexistent_log_dir(self, tmp_path): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + nonexistent = tmp_path / "does_not_exist" + + with patch.object(mod, "get_system_logs_dir", return_value=nonexistent): + # Should not raise + watcher.initialize_positions() + + assert watcher.log_positions == {} + + def test_ignores_non_log_files(self, tmp_path): + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + # Create a .log file and a .txt file + log_file = tmp_path / "test.log" + log_file.write_text("content\n", encoding="utf-8") + txt_file = tmp_path / "test.txt" + txt_file.write_text("not a log\n", encoding="utf-8") + + with patch.object(mod, "get_system_logs_dir", return_value=tmp_path): + watcher.initialize_positions() + + assert str(log_file) in watcher.log_positions + assert str(txt_file) not in watcher.log_positions diff --git a/src/aipass/prax/tests/test_logger_module.py b/src/aipass/prax/tests/test_logger_module.py new file mode 100644 index 00000000..e7491b32 --- /dev/null +++ b/src/aipass/prax/tests/test_logger_module.py @@ -0,0 +1,628 @@ +# =================== AIPass ==================== +# Name: test_logger_module.py +# Description: Unit tests for PRAX logger module (public API) +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for prax logger module — the core public API for system-wide logging. + +logger.py imports from 8+ handler files at module level, so we must inject +mock modules into sys.modules for every handler dependency BEFORE importing +the module under test. The conftest autouse fixture mocks the logger module +itself (for other tests), but here we need to test logger.py internals so +we bypass that and do our own heavier mocking. + +All module imports happen inside test functions so that mocks are in place +before the import chain triggers. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# ============================================= +# HELPERS +# ============================================= + +MODULE_NAME = "aipass.prax.apps.modules.logger" + +# All handler dependencies that logger.py imports at module level +HANDLER_DEPS = [ + "aipass.prax.apps.handlers.logging.setup", + "aipass.prax.apps.handlers.logging.introspection", + "aipass.prax.apps.handlers.logging.override", + "aipass.prax.apps.handlers.logging.direct", + "aipass.prax.apps.handlers.logging.lifecycle", + "aipass.prax.apps.handlers.discovery.watcher", + "aipass.prax.apps.handlers.registry.load", + "aipass.prax.apps.handlers.config.load", + "aipass.prax.apps.handlers.json", + "aipass.cli.apps.modules", + "aipass.cli.apps.modules.display", + "aipass.cli.apps.modules.console", +] + + +def _build_handler_mocks(): + """Create mock modules for every handler dependency. + + Returns a dict of module_path -> MagicMock with the attributes + that logger.py actually uses wired up. + """ + mocks = {} + for dep in HANDLER_DEPS: + mocks[dep] = MagicMock() + + # Wire up specific attributes that logger.py accesses at import time + + # setup.py exports + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + mock_stdlib_logger = MagicMock() + mock_stdlib_logger.info = MagicMock() + mock_stdlib_logger.warning = MagicMock() + mock_stdlib_logger.error = MagicMock() + mock_stdlib_logger.debug = MagicMock() + setup.setup_individual_logger = MagicMock(return_value=mock_stdlib_logger) + setup.get_captured_loggers_count = MagicMock(return_value=5) + setup.enable_terminal_output = MagicMock() + setup.disable_terminal_output = MagicMock() + + # introspection.py exports + intro = mocks["aipass.prax.apps.handlers.logging.introspection"] + intro.get_calling_module = MagicMock(return_value="test_module") + intro.get_caller_info = MagicMock(return_value=("test_module", "/fake/path.py", "prax")) + + # override.py exports + override = mocks["aipass.prax.apps.handlers.logging.override"] + override.is_override_active = MagicMock(return_value=False) + + # direct.py exports + direct = mocks["aipass.prax.apps.handlers.logging.direct"] + mock_direct_logger = MagicMock() + direct.get_direct_logger = MagicMock(return_value=mock_direct_logger) + direct.direct_log = MagicMock() + direct.DirectLogger = MagicMock + + # watcher.py exports + watcher = mocks["aipass.prax.apps.handlers.discovery.watcher"] + watcher.start_file_watcher = MagicMock() + watcher.is_file_watcher_active = MagicMock(return_value=True) + + # registry/load.py exports + registry = mocks["aipass.prax.apps.handlers.registry.load"] + registry.load_module_registry = MagicMock(return_value=[ + {"name": "mod_a"}, {"name": "mod_b"}, {"name": "mod_c"}, + ]) + + # config/load.py exports + config = mocks["aipass.prax.apps.handlers.config.load"] + config.get_system_logs_dir = MagicMock(return_value=Path("/tmp/prax/logs/system")) + config.get_module_logs_dir = MagicMock(return_value=Path("/tmp/prax/logs/modules")) + config.PRAX_JSON_DIR = Path("/tmp/prax/json") + + # json handler + json_mod = mocks["aipass.prax.apps.handlers.json"] + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + json_mod.json_handler = mock_json_handler + + # CLI modules + cli = mocks["aipass.cli.apps.modules"] + mock_console = MagicMock() + mock_console.print = MagicMock() + cli.console = mock_console + + cli_display = mocks["aipass.cli.apps.modules.display"] + cli_display.console = mock_console + + cli_console_mod = mocks["aipass.cli.apps.modules.console"] + cli_console_mod.print = MagicMock() + + return mocks + + +def _inject_and_import(monkeypatch): + """Inject handler mocks into sys.modules and (re-)import logger.py. + + Returns (logger_module, handler_mocks_dict). + """ + mocks = _build_handler_mocks() + + # Clear cached module so it re-imports fresh + sys.modules.pop(MODULE_NAME, None) + + # Inject all handler mocks + for mod_path, mock_obj in mocks.items(): + monkeypatch.setitem(sys.modules, mod_path, mock_obj) + + # Now import — the module-level imports will resolve to our mocks + import aipass.prax.apps.modules.logger as logger_mod + import importlib + importlib.reload(logger_mod) + + return logger_mod, mocks + + +# ============================================= +# get_system_logger +# ============================================= + +class TestGetSystemLogger: + """Tests for get_system_logger() — returns a logger with standard methods.""" + + def test_returns_logger_object(self, monkeypatch): + """get_system_logger returns an object from setup_individual_logger.""" + mod, mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_logger() + + assert result is not None + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + setup.setup_individual_logger.assert_called_once() + + def test_returned_logger_has_info_method(self, monkeypatch): + """The returned logger has an info() method.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_logger() + + assert hasattr(result, "info") + assert callable(result.info) + + def test_returned_logger_has_warning_method(self, monkeypatch): + """The returned logger has a warning() method.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_logger() + + assert hasattr(result, "warning") + assert callable(result.warning) + + def test_returned_logger_has_error_method(self, monkeypatch): + """The returned logger has an error() method.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_logger() + + assert hasattr(result, "error") + assert callable(result.error) + + def test_passes_caller_info_to_setup(self, monkeypatch): + """get_system_logger passes caller_path and caller_branch from introspection.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.get_system_logger() + + intro = mocks["aipass.prax.apps.handlers.logging.introspection"] + intro.get_caller_info.assert_called_once() + + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + call_kwargs = setup.setup_individual_logger.call_args + assert call_kwargs[1]["caller_path"] == "/fake/path.py" + assert call_kwargs[1]["caller_branch"] == "prax" + + +# ============================================= +# get_system_status +# ============================================= + +class TestGetSystemStatus: + """Tests for get_system_status() — returns a dict with system info.""" + + def test_returns_dict(self, monkeypatch): + """get_system_status returns a dictionary.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert isinstance(result, dict) + + def test_contains_total_modules_key(self, monkeypatch): + """Result includes total_modules count from registry.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "total_modules" in result + assert result["total_modules"] == 3 # 3 modules in mock registry + + def test_contains_individual_loggers_key(self, monkeypatch): + """Result includes individual_loggers count.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "individual_loggers" in result + assert result["individual_loggers"] == 5 # from mock + + def test_contains_system_logs_dir(self, monkeypatch): + """Result includes system_logs_dir path.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "system_logs_dir" in result + assert "prax" in result["system_logs_dir"] + + def test_contains_module_logs_dir(self, monkeypatch): + """Result includes module_logs_dir path.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "module_logs_dir" in result + + def test_contains_file_watcher_status(self, monkeypatch): + """Result includes file_watcher_active boolean.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "file_watcher_active" in result + assert result["file_watcher_active"] is True + + def test_contains_override_status(self, monkeypatch): + """Result includes logger_override_active boolean.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "logger_override_active" in result + assert result["logger_override_active"] is False + + def test_contains_registry_file(self, monkeypatch): + """Result includes registry_file path.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.get_system_status() + + assert "registry_file" in result + + def test_calls_load_module_registry(self, monkeypatch): + """get_system_status calls load_module_registry to count modules.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.get_system_status() + + registry = mocks["aipass.prax.apps.handlers.registry.load"] + registry.load_module_registry.assert_called_once() + + +# ============================================= +# handle_command +# ============================================= + +class TestHandleCommand: + """Tests for handle_command() — introspection gate and routing.""" + + def test_no_args_calls_introspection_returns_true(self, monkeypatch): + """Empty args list prints introspection and returns True.""" + mod, mocks = _inject_and_import(monkeypatch) + + result = mod.handle_command("logger", []) + + assert result is True + + def test_help_flag_returns_true(self, monkeypatch): + """--help flag prints introspection and returns True.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.handle_command("logger", ["--help"]) + + assert result is True + + def test_h_flag_returns_true(self, monkeypatch): + """-h flag prints introspection and returns True.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.handle_command("logger", ["-h"]) + + assert result is True + + def test_help_word_returns_true(self, monkeypatch): + """'help' subcommand prints introspection and returns True.""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.handle_command("logger", ["help"]) + + assert result is True + + def test_unknown_arg_returns_false(self, monkeypatch): + """Unknown argument returns False (unhandled).""" + mod, _mocks = _inject_and_import(monkeypatch) + + result = mod.handle_command("logger", ["unknown-subcommand"]) + + assert result is False + + def test_logs_operation_via_json_handler(self, monkeypatch): + """handle_command logs the operation through json_handler.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.handle_command("logger", ["--help"]) + + json_handler = mocks["aipass.prax.apps.handlers.json"].json_handler + json_handler.log_operation.assert_called_once_with( + "logger_handle_command", {"args": ["--help"]} + ) + + def test_no_args_logs_operation(self, monkeypatch): + """handle_command with no args still logs the operation.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.handle_command("logger", []) + + json_handler = mocks["aipass.prax.apps.handlers.json"].json_handler + json_handler.log_operation.assert_called_once_with( + "logger_handle_command", {"args": []} + ) + + +# ============================================= +# initialize_logging_system +# ============================================= + +class TestInitializeLoggingSystem: + """Tests for initialize_logging_system() — delegates to lifecycle handler.""" + + def test_calls_lifecycle_run_initialize(self, monkeypatch): + """initialize_logging_system calls run_initialize from lifecycle handler.""" + mod, mocks = _inject_and_import(monkeypatch) + + # The lifecycle handler is lazy-imported inside the function, + # so we need to mock it in sys.modules + mock_lifecycle = MagicMock() + mock_lifecycle.run_initialize = MagicMock(return_value={ + "modules_count": 42, + }) + monkeypatch.setitem( + sys.modules, + "aipass.prax.apps.handlers.logging.lifecycle", + mock_lifecycle, + ) + + mod.initialize_logging_system() + + mock_lifecycle.run_initialize.assert_called_once_with("prax_logger") + + def test_prints_initialization_message(self, monkeypatch): + """initialize_logging_system prints init and completion messages.""" + mod, mocks = _inject_and_import(monkeypatch) + + mock_lifecycle = MagicMock() + mock_lifecycle.run_initialize = MagicMock(return_value={ + "modules_count": 10, + }) + monkeypatch.setitem( + sys.modules, + "aipass.prax.apps.handlers.logging.lifecycle", + mock_lifecycle, + ) + + mod.initialize_logging_system() + + console = mocks["aipass.cli.apps.modules"].console + calls = [str(c) for c in console.print.call_args_list] + assert any("Initializing" in c for c in calls) + assert any("initialized" in c.lower() for c in calls) + + +# ============================================= +# shutdown_logging_system +# ============================================= + +class TestShutdownLoggingSystem: + """Tests for shutdown_logging_system() — delegates to lifecycle handler.""" + + def test_calls_lifecycle_run_shutdown(self, monkeypatch): + """shutdown_logging_system calls run_shutdown from lifecycle handler.""" + mod, mocks = _inject_and_import(monkeypatch) + + mock_lifecycle = MagicMock() + mock_lifecycle.run_shutdown = MagicMock() + monkeypatch.setitem( + sys.modules, + "aipass.prax.apps.handlers.logging.lifecycle", + mock_lifecycle, + ) + + mod.shutdown_logging_system() + + mock_lifecycle.run_shutdown.assert_called_once_with("prax_logger") + + def test_prints_shutdown_messages(self, monkeypatch): + """shutdown_logging_system prints shutdown and completion messages.""" + mod, mocks = _inject_and_import(monkeypatch) + + mock_lifecycle = MagicMock() + mock_lifecycle.run_shutdown = MagicMock() + monkeypatch.setitem( + sys.modules, + "aipass.prax.apps.handlers.logging.lifecycle", + mock_lifecycle, + ) + + mod.shutdown_logging_system() + + console = mocks["aipass.cli.apps.modules"].console + calls = [str(c) for c in console.print.call_args_list] + assert any("Shutting down" in c for c in calls) + assert any("complete" in c.lower() for c in calls) + + +# ============================================= +# enable_terminal_output / disable_terminal_output +# ============================================= + +class TestTerminalOutputControl: + """Tests for enable/disable terminal output pass-through functions.""" + + def test_enable_delegates_to_setup_handler(self, monkeypatch): + """enable_terminal_output calls the setup handler's enable function.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.enable_terminal_output() + + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + setup.enable_terminal_output.assert_called_once() + + def test_disable_delegates_to_setup_handler(self, monkeypatch): + """disable_terminal_output calls the setup handler's disable function.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.disable_terminal_output() + + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + setup.disable_terminal_output.assert_called_once() + + +# ============================================= +# SystemLogger class +# ============================================= + +class TestSystemLogger: + """Tests for the SystemLogger class — auto-routing logger proxy.""" + + def test_system_logger_instance_exists(self, monkeypatch): + """Module exports a system_logger instance of SystemLogger.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert hasattr(mod, "system_logger") + assert isinstance(mod.system_logger, mod.SystemLogger) + + def test_system_logger_has_info(self, monkeypatch): + """SystemLogger exposes an info() method.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert callable(getattr(mod.system_logger, "info", None)) + + def test_system_logger_has_warning(self, monkeypatch): + """SystemLogger exposes a warning() method.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert callable(getattr(mod.system_logger, "warning", None)) + + def test_system_logger_has_error(self, monkeypatch): + """SystemLogger exposes an error() method.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert callable(getattr(mod.system_logger, "error", None)) + + def test_info_calls_get_system_logger(self, monkeypatch): + """SystemLogger.info() delegates to get_system_logger().info().""" + mod, mocks = _inject_and_import(monkeypatch) + + # Reset watcher flag so _ensure_watcher runs + mod.SystemLogger._watcher_started = True + + mod.system_logger.info("test message %s", "arg1") + + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + setup.setup_individual_logger.assert_called() + + def test_warning_calls_get_system_logger(self, monkeypatch): + """SystemLogger.warning() delegates to get_system_logger().warning().""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.SystemLogger._watcher_started = True + + mod.system_logger.warning("warn: %s", "problem") + + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + setup.setup_individual_logger.assert_called() + + def test_error_calls_get_system_logger(self, monkeypatch): + """SystemLogger.error() delegates to get_system_logger().error().""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.SystemLogger._watcher_started = True + + mod.system_logger.error("error: %s", "failure") + + setup = mocks["aipass.prax.apps.handlers.logging.setup"] + setup.setup_individual_logger.assert_called() + + +# ============================================= +# Module constants +# ============================================= + +class TestModuleConstants: + """Tests for module-level constants.""" + + def test_module_name_is_prax_logger(self, monkeypatch): + """MODULE_NAME is set to 'prax_logger'.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert mod.MODULE_NAME == "prax_logger" + + def test_data_file_is_path(self, monkeypatch): + """DATA_FILE is a pathlib.Path.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert isinstance(mod.DATA_FILE, Path) + + def test_data_file_contains_module_name(self, monkeypatch): + """DATA_FILE filename includes the module name.""" + mod, _mocks = _inject_and_import(monkeypatch) + + assert "prax_logger" in mod.DATA_FILE.name + + +# ============================================= +# print_introspection +# ============================================= + +class TestPrintIntrospection: + """Tests for print_introspection() — displays module info.""" + + def test_prints_handler_info(self, monkeypatch): + """print_introspection prints handler connection details.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.print_introspection() + + console = mocks["aipass.cli.apps.modules.display"].console + calls = [str(c) for c in console.print.call_args_list] + assert any("Connected Handlers" in c for c in calls) + + def test_prints_logger_module_name(self, monkeypatch): + """print_introspection mentions the logger module.""" + mod, mocks = _inject_and_import(monkeypatch) + + mod.print_introspection() + + console = mocks["aipass.cli.apps.modules.display"].console + calls = [str(c) for c in console.print.call_args_list] + assert any("logger" in c.lower() for c in calls) + + def test_fallback_to_rich_when_cli_unavailable(self, monkeypatch): + """print_introspection falls back to rich Console if CLI import fails.""" + mod, mocks = _inject_and_import(monkeypatch) + + # Make the CLI display import raise ImportError + cli_display_mock = mocks["aipass.cli.apps.modules.display"] + cli_display_mock.console = MagicMock(side_effect=ImportError("no CLI")) + + # Remove the display module so the import inside print_introspection fails + monkeypatch.delitem(sys.modules, "aipass.cli.apps.modules.display", raising=False) + + # Should not raise — falls back to rich.console.Console + with patch("rich.console.Console") as mock_rich: + mock_rich_instance = MagicMock() + mock_rich.return_value = mock_rich_instance + + # Force re-import to pick up removed module + sys.modules.pop(MODULE_NAME, None) + import importlib + import aipass.prax.apps.modules.logger as fresh_mod + importlib.reload(fresh_mod) + + fresh_mod.print_introspection() + + mock_rich.assert_called_once() + mock_rich_instance.print.assert_called() diff --git a/src/aipass/prax/tests/test_monitor_module.py b/src/aipass/prax/tests/test_monitor_module.py new file mode 100644 index 00000000..e8e1bac6 --- /dev/null +++ b/src/aipass/prax/tests/test_monitor_module.py @@ -0,0 +1,224 @@ +# =================== AIPass ==================== +# Name: test_monitor_module.py +# Description: Tests for the unified monitoring module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for apps/modules/monitor.py + +Covers: +- handle_command() dispatching (introspection, help, run, unknown subcommands) +- _get_watch_directories() registry-based directory enumeration +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# handle_command tests +# --------------------------------------------------------------------------- + +class TestHandleCommand: + """Test the top-level command router.""" + + def _import_monitor(self): + """Import monitor module fresh (after conftest mocks are in place).""" + # Additional mocks for monitoring handler imports + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.monitoring": MagicMock(), + "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), + "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), + "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), + "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), + "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), + "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), + "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), + "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), + "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), + }): + import importlib + if "aipass.prax.apps.modules.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.modules.monitor") + return mod + + def test_returns_false_for_non_monitor_command(self): + """handle_command returns False for commands other than 'monitor'.""" + mod = self._import_monitor() + assert mod.handle_command("status", []) is False + + def test_no_args_calls_print_introspection(self): + """Bare 'monitor' with no args shows introspection and returns True.""" + mod = self._import_monitor() + with patch.object(mod, "print_introspection") as mock_intro: + result = mod.handle_command("monitor", []) + assert result is True + mock_intro.assert_called_once() + + def test_help_flag_calls_print_help(self): + """--help flag shows help and returns True.""" + mod = self._import_monitor() + for flag in ("--help", "-h", "help"): + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("monitor", [flag]) + assert result is True + mock_help.assert_called_once() + + def test_run_subcommand_calls_run_monitor(self): + """'run' subcommand delegates to _run_monitor.""" + mod = self._import_monitor() + with patch.object(mod, "_run_monitor", return_value=True) as mock_run: + result = mod.handle_command("monitor", ["run"]) + assert result is True + mock_run.assert_called_once_with([]) + + def test_run_subcommand_passes_trailing_args(self): + """Extra args after 'run' are forwarded to _run_monitor.""" + mod = self._import_monitor() + with patch.object(mod, "_run_monitor", return_value=True) as mock_run: + mod.handle_command("monitor", ["run", "seedgo,cli"]) + mock_run.assert_called_once_with(["seedgo,cli"]) + + def test_unknown_subcommand_prints_error_and_help(self): + """Unknown subcommand shows error + help, returns True.""" + mod = self._import_monitor() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("monitor", ["bogus"]) + assert result is True + mock_help.assert_called_once() + # error() is the CLI mock -- check that it was called + mod.error.assert_called() + + +# --------------------------------------------------------------------------- +# _get_watch_directories tests +# --------------------------------------------------------------------------- + +class TestGetWatchDirectories: + """Test directory enumeration from registry.""" + + def _import_monitor(self): + with patch.dict(sys.modules, { + "aipass.prax.apps.handlers.monitoring": MagicMock(), + "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), + "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), + "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), + "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), + "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), + "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), + "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), + "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), + "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), + }): + import importlib + if "aipass.prax.apps.modules.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.modules.monitor") + return mod + + def test_returns_empty_when_no_registry(self, tmp_path): + """No AIPASS_REGISTRY.json -> empty list (no .claude/projects either).""" + mod = self._import_monitor() + with patch("pathlib.Path.home", return_value=tmp_path / "fakehome"): + result = mod._get_watch_directories(tmp_path) + assert result == [] + + def test_includes_apps_and_trinity_dirs(self, tmp_path): + """Registry with a branch that has apps/ and .trinity/ dirs.""" + mod = self._import_monitor() + # Set up fake branch structure + branch_dir = tmp_path / "src" / "aipass" / "flow" + apps_dir = branch_dir / "apps" + trinity_dir = branch_dir / ".trinity" + apps_dir.mkdir(parents=True) + trinity_dir.mkdir(parents=True) + + registry = { + "branches": [ + {"name": "flow", "path": "src/aipass/flow"} + ] + } + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path / "fakehome"): + result = mod._get_watch_directories(tmp_path) + + paths = [p for p, _r in result] + recursives = {str(p): r for p, r in result} + + assert apps_dir in paths, "apps/ directory should be included" + assert branch_dir in paths, "branch root should be included" + assert trinity_dir in paths, "trinity/ directory should be included" + + # apps/ is watched recursively, branch root and .trinity are not + assert recursives[str(apps_dir)] is True + assert recursives[str(branch_dir)] is False + assert recursives[str(trinity_dir)] is False + + def test_skips_nonexistent_branch_paths(self, tmp_path): + """Branches whose path doesn't exist on disk are skipped.""" + mod = self._import_monitor() + registry = { + "branches": [ + {"name": "ghost", "path": "src/aipass/ghost"} + ] + } + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path / "fakehome"): + result = mod._get_watch_directories(tmp_path) + + assert result == [] + + def test_includes_claude_projects_when_exists(self, tmp_path): + """~/.claude/projects is included when it exists.""" + mod = self._import_monitor() + fakehome = tmp_path / "fakehome" + claude_projects = fakehome / ".claude" / "projects" + claude_projects.mkdir(parents=True) + + # No registry needed for this test + with patch("pathlib.Path.home", return_value=fakehome): + result = mod._get_watch_directories(tmp_path) + + paths = [p for p, _r in result] + assert claude_projects in paths + recursives = {str(p): r for p, r in result} + assert recursives[str(claude_projects)] is True + + def test_branch_without_apps_dir(self, tmp_path): + """Branch that exists but has no apps/ dir still includes root and .trinity.""" + mod = self._import_monitor() + branch_dir = tmp_path / "src" / "aipass" / "minimal" + trinity_dir = branch_dir / ".trinity" + branch_dir.mkdir(parents=True) + trinity_dir.mkdir(parents=True) + + registry = { + "branches": [ + {"name": "minimal", "path": "src/aipass/minimal"} + ] + } + registry_file = tmp_path / "AIPASS_REGISTRY.json" + registry_file.write_text(json.dumps(registry), encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path / "fakehome"): + result = mod._get_watch_directories(tmp_path) + + paths = [p for p, _r in result] + # No apps/ entry + assert (branch_dir / "apps") not in paths + # But branch root and .trinity are still there + assert branch_dir in paths + assert trinity_dir in paths diff --git a/src/aipass/prax/tests/test_registry.py b/src/aipass/prax/tests/test_registry.py new file mode 100644 index 00000000..e61d8f80 --- /dev/null +++ b/src/aipass/prax/tests/test_registry.py @@ -0,0 +1,339 @@ +# =================== AIPass ==================== +# Name: test_registry.py +# Description: Tests for registry load and save handlers +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for apps/handlers/registry/load.py and save.py. + +Covers: load_module_registry (valid file, missing file, corrupt file, +missing modules key) and save_module_registry (writes valid JSON, +creates directory, round-trip with load, error handling). +""" + +import json +import sys +import importlib +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +# ============================================= +# HELPERS +# ============================================= + +def _fresh_import_registry_load(monkeypatch, tmp_path): + """Import registry load module with paths redirected to tmp_path.""" + for key in list(sys.modules.keys()): + if "aipass.prax.apps.handlers.registry" in key: + sys.modules.pop(key, None) + + import aipass.prax.apps.handlers.registry.load as load_mod + + prax_json_dir = tmp_path / "prax_json" + prax_json_dir.mkdir(exist_ok=True) + registry_file = prax_json_dir / "prax_registry.json" + + monkeypatch.setattr(load_mod, "PRAX_JSON_DIR", prax_json_dir) + monkeypatch.setattr(load_mod, "REGISTRY_FILE", registry_file) + + # Replace stdlib logger with a MagicMock so tests can assert on calls + mock_logger = MagicMock() + monkeypatch.setattr(load_mod, "logger", mock_logger) + + return load_mod + + +def _fresh_import_registry_save(monkeypatch, tmp_path): + """Import registry save module with paths redirected to tmp_path.""" + for key in list(sys.modules.keys()): + if "aipass.prax.apps.handlers.registry" in key: + sys.modules.pop(key, None) + + import aipass.prax.apps.handlers.registry.save as save_mod + + prax_json_dir = tmp_path / "prax_json" + # Do NOT create the dir here -- save should create it itself + registry_file = prax_json_dir / "prax_registry.json" + + monkeypatch.setattr(save_mod, "PRAX_JSON_DIR", prax_json_dir) + monkeypatch.setattr(save_mod, "REGISTRY_FILE", registry_file) + monkeypatch.setattr(save_mod, "ECOSYSTEM_ROOT", tmp_path / "ecosystem") + + # Replace stdlib logger with a MagicMock so tests can assert on calls + mock_logger = MagicMock() + monkeypatch.setattr(save_mod, "logger", mock_logger) + + return save_mod + + +# ============================================= +# TESTS: load_module_registry +# ============================================= + +class TestLoadModuleRegistry: + """Tests for load_module_registry().""" + + def test_returns_dict(self, mock_prax_infrastructure, monkeypatch, tmp_path): + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + result = load_mod.load_module_registry() + assert isinstance(result, dict) + + def test_empty_dict_when_file_missing(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Missing registry file should return empty dict.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + result = load_mod.load_module_registry() + assert result == {} + + def test_loads_modules_from_valid_file(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Valid registry file should return the modules dict.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + + modules = { + "prax": {"relative_path": "src/aipass/prax", "size": 1024}, + "flow": {"relative_path": "src/aipass/flow", "size": 2048}, + } + registry = { + "registry_version": "1.0.0", + "timestamp": "2026-04-03T00:00:00+00:00", + "modules": modules, + "statistics": {"total_modules": 2}, + } + load_mod.REGISTRY_FILE.write_text( + json.dumps(registry), encoding="utf-8" + ) + + result = load_mod.load_module_registry() + assert len(result) == 2 + assert result["prax"]["relative_path"] == "src/aipass/prax" + assert result["flow"]["size"] == 2048 + + def test_empty_dict_on_corrupt_json(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Corrupt JSON should return empty dict, not raise.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + load_mod.REGISTRY_FILE.write_text("<<>>", encoding="utf-8") + + result = load_mod.load_module_registry() + assert result == {} + + def test_empty_dict_when_modules_key_missing( + self, mock_prax_infrastructure, monkeypatch, tmp_path + ): + """Registry without 'modules' key should return empty dict.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + load_mod.REGISTRY_FILE.write_text( + json.dumps({"registry_version": "1.0.0"}), encoding="utf-8" + ) + + result = load_mod.load_module_registry() + assert result == {} + + def test_empty_modules_returns_empty_dict( + self, mock_prax_infrastructure, monkeypatch, tmp_path + ): + """Registry with empty modules dict should return empty dict.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + load_mod.REGISTRY_FILE.write_text( + json.dumps({"modules": {}}), encoding="utf-8" + ) + + result = load_mod.load_module_registry() + assert result == {} + + def test_logs_operation_on_success(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should call json_handler.log_operation on successful load.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + + registry = { + "modules": {"mod_a": {"path": "a"}, "mod_b": {"path": "b"}}, + } + load_mod.REGISTRY_FILE.write_text( + json.dumps(registry), encoding="utf-8" + ) + + load_mod.load_module_registry() + load_mod.json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] + "registry_loaded", {"module_count": 2} + ) + + def test_logs_warning_on_corrupt_file(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should log warning when file is corrupt.""" + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + load_mod.REGISTRY_FILE.write_text("broken!", encoding="utf-8") + + load_mod.load_module_registry() + load_mod.logger.warning.assert_called() # type: ignore[union-attr] + + +# ============================================= +# TESTS: save_module_registry +# ============================================= + +class TestSaveModuleRegistry: + """Tests for save_module_registry().""" + + def test_returns_true_on_success(self, mock_prax_infrastructure, monkeypatch, tmp_path): + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + result = save_mod.save_module_registry({"mod": {"path": "x"}}) + assert result is True + + def test_creates_directory_if_missing(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should create prax_json directory if it doesn't exist.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + prax_json_dir = save_mod.PRAX_JSON_DIR + assert not prax_json_dir.exists() + + save_mod.save_module_registry({"mod": {"path": "x"}}) + assert prax_json_dir.exists() + assert prax_json_dir.is_dir() + + def test_writes_valid_json(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Saved file should contain valid JSON.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + modules = {"test_mod": {"relative_path": "test/mod.py", "size": 100}} + + save_mod.save_module_registry(modules) + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + assert isinstance(data, dict) + + def test_saved_structure_has_required_keys( + self, mock_prax_infrastructure, monkeypatch, tmp_path + ): + """Saved JSON should contain registry_version, timestamp, modules, statistics.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + modules = {"alpha": {"relative_path": "src/alpha.py"}} + + save_mod.save_module_registry(modules) + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + assert "registry_version" in data + assert "timestamp" in data + assert "modules" in data + assert "statistics" in data + + def test_saved_modules_match_input(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """The modules dict in the saved file should match what was passed in.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + modules = { + "alpha": {"relative_path": "src/alpha.py", "size": 500}, + "beta": {"relative_path": "src/beta.py", "size": 750}, + } + + save_mod.save_module_registry(modules) + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + assert data["modules"] == modules + + def test_statistics_total_modules(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Statistics should reflect the correct total_modules count.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + modules = {f"mod_{i}": {"path": f"p{i}"} for i in range(5)} + + save_mod.save_module_registry(modules) + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + assert data["statistics"]["total_modules"] == 5 + + def test_registry_version_is_string(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """registry_version should be a version string.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + save_mod.save_module_registry({"x": {"p": "q"}}) + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + assert data["registry_version"] == "1.0.0" + + def test_timestamp_is_iso_format(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Timestamp should be a valid ISO-format UTC string.""" + from datetime import datetime + + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + save_mod.save_module_registry({"x": {"p": "q"}}) + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + # Should not raise on valid ISO format + parsed = datetime.fromisoformat(data["timestamp"]) + assert parsed is not None + + def test_save_empty_modules(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Saving empty modules dict should succeed with total_modules=0.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + result = save_mod.save_module_registry({}) + assert result is True + + data = json.loads(save_mod.REGISTRY_FILE.read_text(encoding="utf-8")) + assert data["modules"] == {} + assert data["statistics"]["total_modules"] == 0 + + def test_returns_false_on_write_error(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should return False when the file write fails.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + + # Make PRAX_JSON_DIR point to a path that will fail mkdir + # by setting it to a file (not a directory) + blocker = tmp_path / "blocker_file" + blocker.write_text("I am a file", encoding="utf-8") + monkeypatch.setattr(save_mod, "PRAX_JSON_DIR", blocker / "subdir") + monkeypatch.setattr(save_mod, "REGISTRY_FILE", blocker / "subdir" / "reg.json") + + result = save_mod.save_module_registry({"x": {}}) + assert result is False + + def test_logs_operation_on_success(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should call json_handler.log_operation after successful save.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + modules = {"a": {"p": "1"}, "b": {"p": "2"}, "c": {"p": "3"}} + + save_mod.save_module_registry(modules) + save_mod.json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] + "registry_saved", {"total_modules": 3} + ) + + def test_logs_error_on_failure(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Should log error when save fails.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + + blocker = tmp_path / "blocker_file" + blocker.write_text("I am a file", encoding="utf-8") + monkeypatch.setattr(save_mod, "PRAX_JSON_DIR", blocker / "subdir") + monkeypatch.setattr(save_mod, "REGISTRY_FILE", blocker / "subdir" / "reg.json") + + save_mod.save_module_registry({"x": {}}) + save_mod.logger.error.assert_called() # type: ignore[union-attr] + + +# ============================================= +# TESTS: round-trip (save then load) +# ============================================= + +class TestRegistryRoundTrip: + """Integration-style tests: save then load.""" + + def test_round_trip_preserves_data(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Data saved by save_module_registry should be loadable by load_module_registry.""" + # Import both with the same tmp_path + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + save_mod.save_module_registry({ + "alpha": {"relative_path": "src/alpha.py", "size": 100}, + "beta": {"relative_path": "src/beta.py", "size": 200}, + }) + + # Now import load pointing at the same directory + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + + result = load_mod.load_module_registry() + assert len(result) == 2 + assert result["alpha"]["relative_path"] == "src/alpha.py" + assert result["beta"]["size"] == 200 + + def test_round_trip_empty_modules(self, mock_prax_infrastructure, monkeypatch, tmp_path): + """Round-trip with empty modules should yield empty dict.""" + save_mod = _fresh_import_registry_save(monkeypatch, tmp_path) + save_mod.save_module_registry({}) + + load_mod = _fresh_import_registry_load(monkeypatch, tmp_path) + result = load_mod.load_module_registry() + assert result == {} diff --git a/src/aipass/prax/tests/test_watcher.py b/src/aipass/prax/tests/test_watcher.py new file mode 100644 index 00000000..72ea8fca --- /dev/null +++ b/src/aipass/prax/tests/test_watcher.py @@ -0,0 +1,333 @@ +# =================== AIPass ==================== +# Name: test_watcher.py +# Description: Tests for file system watcher handlers +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for: +- apps/handlers/watcher/monitor.py (BranchFileHandler, start/stop_monitoring) +- apps/handlers/discovery/watcher.py (PythonFileWatcher, start/stop_file_watcher) +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# ============================================================================ +# WATCHER/MONITOR.PY - BranchFileHandler and start/stop_monitoring +# ============================================================================ + +class TestBranchFileHandler: + """Tests for BranchFileHandler event callbacks and filtering.""" + + def _make_handler(self): + """Create a BranchFileHandler with a mock callback.""" + # Provide a real base class so subclass methods work properly + class _RealFSHandler: + """Stub base so BranchFileHandler methods are not swallowed.""" + pass + + mock_watchdog_events = MagicMock() + mock_watchdog_events.FileSystemEventHandler = _RealFSHandler + mock_watchdog_events.FileSystemEvent = MagicMock() + mock_watchdog_observer = MagicMock() + with patch.dict(sys.modules, { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + }): + import importlib + if "aipass.prax.apps.handlers.watcher.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.watcher.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.handlers.watcher.monitor") + + callback = MagicMock() + handler = mod.BranchFileHandler("TEST", callback) + return handler, callback, mod + + def _make_event(self, src_path: str, is_directory: bool = False, dest_path: str | None = None): + event = MagicMock() + event.src_path = src_path + event.is_directory = is_directory + if dest_path is not None: + event.dest_path = dest_path + return event + + # --- Callback firing tests --- + + def test_on_created_fires_callback(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/src/aipass/flow/apps/module.py") + handler.on_created(event) + callback.assert_called_once_with("TEST", "CREATED", "/repo/src/aipass/flow/apps/module.py") + + def test_on_modified_fires_callback(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/src/aipass/flow/apps/module.py") + handler.on_modified(event) + callback.assert_called_once_with("TEST", "MODIFIED", "/repo/src/aipass/flow/apps/module.py") + + def test_on_deleted_fires_callback(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/src/aipass/flow/apps/module.py") + handler.on_deleted(event) + callback.assert_called_once_with("TEST", "DELETED", "/repo/src/aipass/flow/apps/module.py") + + def test_on_moved_fires_callback_with_arrow(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/old.py", dest_path="/repo/new.py") + handler.on_moved(event) + callback.assert_called_once_with("TEST", "MOVED", "/repo/old.py \u2192 /repo/new.py") + + # --- Ignore logic --- + + def test_ignores_directory_events(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/src/aipass/flow/apps/", is_directory=True) + handler.on_created(event) + callback.assert_not_called() + + def test_ignores_log_files(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/logs/prax.log") + handler.on_modified(event) + callback.assert_not_called() + + def test_ignores_tmp_files(self): + handler, callback, _mod = self._make_handler() + for path in ("/repo/data.tmp", "/repo/.tmp.xyz"): + event = self._make_event(path) + handler.on_created(event) + callback.assert_not_called() + + def test_ignores_backup_files(self): + handler, callback, _mod = self._make_handler() + for path in ("/repo/file.backup", "/repo/file.bak", "/repo/file~"): + event = self._make_event(path) + handler.on_modified(event) + callback.assert_not_called() + + def test_ignores_vim_swap_files(self): + handler, callback, _mod = self._make_handler() + for path in ("/repo/.file.swp", "/repo/.file.swo"): + event = self._make_event(path) + handler.on_created(event) + callback.assert_not_called() + + def test_ignores_system_directories(self): + handler, callback, _mod = self._make_handler() + ignore_paths = [ + "/repo/.claude/settings.json", + "/repo/.git/objects/abc", + "/repo/__pycache__/mod.pyc", + "/repo/.pytest_cache/v/cache.json", + "/repo/node_modules/pkg/index.js", + "/repo/.venv/lib/site.py", + "/repo/venv/lib/site.py", + "/repo/.local/share/data", + "/repo/.cache/fontconfig", + "/repo/.config/user.json", + "/repo/.vscode/settings.json", + "/repo/system_logs/prax.log", + ] + for path in ignore_paths: + event = self._make_event(path) + handler.on_modified(event) + callback.assert_not_called() + + def test_does_not_ignore_normal_python_file(self): + handler, callback, _mod = self._make_handler() + event = self._make_event("/repo/src/aipass/prax/apps/modules/status.py") + handler.on_modified(event) + callback.assert_called_once() + + +# ============================================================================ +# WATCHER/MONITOR.PY - start_monitoring / stop_monitoring +# ============================================================================ + +class TestStartStopMonitoring: + """Tests for start_monitoring and stop_monitoring functions.""" + + def _import_watcher_monitor(self): + mock_observer_cls = MagicMock() + mock_observer_instance = MagicMock() + mock_observer_cls.return_value = mock_observer_instance + + mock_watchdog_observer = MagicMock() + mock_watchdog_observer.Observer = mock_observer_cls + + mock_watchdog_events = MagicMock() + + with patch.dict(sys.modules, { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + }): + import importlib + if "aipass.prax.apps.handlers.watcher.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.watcher.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.handlers.watcher.monitor") + + # Force WATCHDOG_AVAILABLE = True and Observer to be our mock + setattr(mod, "WATCHDOG_AVAILABLE", True) + setattr(mod, "Observer", mock_observer_cls) + + return mod, mock_observer_instance, mock_observer_cls + + def test_start_monitoring_schedules_paths(self, tmp_path): + mod, observer_inst, _cls = self._import_watcher_monitor() + branch_dir = tmp_path / "flow" + branch_dir.mkdir() + callback = MagicMock() + + result = mod.start_monitoring([("FLOW", branch_dir)], callback) + + assert result is observer_inst + observer_inst.schedule.assert_called_once() + observer_inst.start.assert_called_once() + + def test_start_monitoring_skips_nonexistent_paths(self, tmp_path): + mod, observer_inst, _cls = self._import_watcher_monitor() + callback = MagicMock() + + result = mod.start_monitoring([("GHOST", tmp_path / "nonexistent")], callback) + + assert result is observer_inst + observer_inst.schedule.assert_not_called() + observer_inst.start.assert_called_once() + + def test_start_monitoring_returns_none_when_watchdog_unavailable(self): + mod, _inst, _cls = self._import_watcher_monitor() + setattr(mod, "WATCHDOG_AVAILABLE", False) + result = mod.start_monitoring([], MagicMock()) + assert result is None + + def test_stop_monitoring_stops_and_joins(self): + mod, observer_inst, _cls = self._import_watcher_monitor() + mod.stop_monitoring(observer_inst) + observer_inst.stop.assert_called_once() + observer_inst.join.assert_called_once() + + def test_stop_monitoring_handles_none(self): + mod, _inst, _cls = self._import_watcher_monitor() + # Should not raise + mod.stop_monitoring(None) + + +# ============================================================================ +# DISCOVERY/WATCHER.PY - PythonFileWatcher, start/stop/is_active +# ============================================================================ + +class TestDiscoveryWatcher: + """Tests for the discovery watcher that registers new Python modules.""" + + def _import_discovery_watcher(self): + mock_observer_cls = MagicMock() + mock_observer_instance = MagicMock() + mock_observer_cls.return_value = mock_observer_instance + + mock_watchdog_observer = MagicMock() + mock_watchdog_observer.Observer = mock_observer_cls + + mock_watchdog_events = MagicMock() + + mock_config = MagicMock() + mock_config.ECOSYSTEM_ROOT = Path("/fake/ecosystem") + mock_config.get_system_logs_dir.return_value = Path("/fake/logs/system") + mock_config.get_module_logs_dir.return_value = Path("/fake/logs/modules") + + mock_registry_load = MagicMock() + mock_registry_load.load_module_registry.return_value = {} + + mock_registry_save = MagicMock() + + mock_filtering = MagicMock() + mock_filtering.should_ignore_path.return_value = False + + mock_trigger_mod = MagicMock() + + with patch.dict(sys.modules, { + "watchdog": MagicMock(), + "watchdog.observers": mock_watchdog_observer, + "watchdog.events": mock_watchdog_events, + "aipass.prax.apps.handlers.config.load": mock_config, + "aipass.prax.apps.handlers.registry.load": mock_registry_load, + "aipass.prax.apps.handlers.registry.save": mock_registry_save, + "aipass.prax.apps.handlers.discovery.filtering": mock_filtering, + "aipass.trigger": MagicMock(), + "aipass.trigger.apps": MagicMock(), + "aipass.trigger.apps.modules": MagicMock(), + "aipass.trigger.apps.modules.core": mock_trigger_mod, + }): + import importlib + if "aipass.prax.apps.handlers.discovery.watcher" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.discovery.watcher"]) + else: + mod = importlib.import_module("aipass.prax.apps.handlers.discovery.watcher") + + setattr(mod, "WatchdogObserver", mock_observer_cls) + return mod, mock_observer_instance, mock_observer_cls + + def test_start_file_watcher_creates_and_starts_observer(self): + mod, observer_inst, _cls = self._import_discovery_watcher() + setattr(mod, "_observer", None) # Ensure clean state + mod.start_file_watcher() + observer_inst.schedule.assert_called_once() + observer_inst.start.assert_called_once() + assert getattr(mod, "_observer") is observer_inst + + def test_start_file_watcher_skips_if_already_running(self): + mod, observer_inst, obs_cls = self._import_discovery_watcher() + existing_observer = MagicMock() + existing_observer.is_alive.return_value = True + setattr(mod, "_observer", existing_observer) + + mod.start_file_watcher() + + # Should not create a new observer + observer_inst.start.assert_not_called() + + def test_stop_file_watcher_stops_and_clears(self): + mod, _inst, _cls = self._import_discovery_watcher() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = True + setattr(mod, "_observer", mock_obs) + + mod.stop_file_watcher() + + mock_obs.stop.assert_called_once() + mock_obs.join.assert_called_once() + assert getattr(mod, "_observer") is None + + def test_stop_file_watcher_noop_when_not_running(self): + mod, _inst, _cls = self._import_discovery_watcher() + setattr(mod, "_observer", None) + # Should not raise + mod.stop_file_watcher() + + def test_is_file_watcher_active_true_when_alive(self): + mod, _inst, _cls = self._import_discovery_watcher() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = True + setattr(mod, "_observer", mock_obs) + assert mod.is_file_watcher_active() is True + + def test_is_file_watcher_active_false_when_none(self): + mod, _inst, _cls = self._import_discovery_watcher() + setattr(mod, "_observer", None) + assert mod.is_file_watcher_active() is False + + def test_is_file_watcher_active_false_when_dead(self): + mod, _inst, _cls = self._import_discovery_watcher() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = False + setattr(mod, "_observer", mock_obs) + assert mod.is_file_watcher_active() is False diff --git a/src/aipass/spawn/tests/test_file_ops.py b/src/aipass/spawn/tests/test_file_ops.py new file mode 100644 index 00000000..9a48ca7c --- /dev/null +++ b/src/aipass/spawn/tests/test_file_ops.py @@ -0,0 +1,450 @@ +# =================== AIPass ==================== +# Name: test_file_ops.py +# Description: Tests for file_ops handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Comprehensive tests for aipass.spawn.apps.handlers.file_ops module.""" + +import hashlib +import json +from pathlib import Path +from unittest.mock import patch + +from aipass.spawn.apps.handlers.file_ops import ( + SKIP_NAMES, + _replace_path_placeholders, + _should_skip, + _walk, + copy_template, + ensure_directory, + regenerate_template_registry, + rename_placeholder_paths, +) + +# --------------------------------------------------------------------------- +# Standard replacements dict used across copy_template tests +# --------------------------------------------------------------------------- + +REPLACEMENTS = { + "BRANCHNAME": "TESTAGENT", + "branchname": "testagent", + "BRANCH": "testagent", + "DATE": "2026-01-01", + "MODULE": "testagent", + "CWD": "/tmp/test", +} + + +# --------------------------------------------------------------------------- +# ensure_directory +# --------------------------------------------------------------------------- + + +class TestEnsureDirectory: + """Tests for ensure_directory().""" + + def test_ensure_directory_creates_nested(self, tmp_path: Path) -> None: + """Verify mkdir -p behaviour: deeply nested path is created.""" + target = tmp_path / "a" / "b" / "c" + assert not target.exists() + + ensure_directory(target) + + assert target.exists() + assert target.is_dir() + + def test_ensure_directory_existing_noop(self, tmp_path: Path) -> None: + """Calling on an existing directory raises no error.""" + target = tmp_path / "already_here" + target.mkdir() + assert target.exists() + + # Should not raise + ensure_directory(target) + + assert target.exists() + assert target.is_dir() + + +# --------------------------------------------------------------------------- +# copy_template +# --------------------------------------------------------------------------- + + +class TestCopyTemplate: + """Tests for copy_template().""" + + def test_copy_template_basic( + self, tmp_path: Path, mock_json_handler + ) -> None: + """Template file with placeholder is copied with content replaced.""" + template = tmp_path / "template" + template.mkdir() + (template / "readme.txt").write_text( + "Hello {{BRANCHNAME}} created on {{DATE}}", + encoding="utf-8", + ) + + target = tmp_path / "target" + target.mkdir() + + copied, _skipped = copy_template(template, target, REPLACEMENTS) + + result = (target / "readme.txt").read_text(encoding="utf-8") + assert result == "Hello TESTAGENT created on 2026-01-01" + assert "readme.txt" in copied + mock_json_handler.assert_called_once() + + def test_copy_template_skips_pycache( + self, tmp_path: Path, mock_json_handler + ) -> None: + """__pycache__ directories and their contents are skipped.""" + _ = mock_json_handler + template = tmp_path / "template" + pycache = template / "__pycache__" + pycache.mkdir(parents=True) + (pycache / "mod.cpython-312.pyc").write_bytes(b"\x00\x01\x02") + + target = tmp_path / "target" + target.mkdir() + + _copied, skipped = copy_template(template, target, REPLACEMENTS) + + assert not (target / "__pycache__").exists() + assert any("__pycache__" in s for s in skipped) + + def test_copy_template_skips_template_registry( + self, tmp_path: Path, mock_json_handler + ) -> None: + """.template_registry.json is skipped during copy.""" + _ = mock_json_handler + template = tmp_path / "template" + template.mkdir() + (template / ".template_registry.json").write_text( + "{}", encoding="utf-8" + ) + (template / "keep.txt").write_text("keep", encoding="utf-8") + + target = tmp_path / "target" + target.mkdir() + + _copied, skipped = copy_template(template, target, REPLACEMENTS) + + assert not (target / ".template_registry.json").exists() + assert (target / "keep.txt").exists() + assert any(".template_registry.json" in s for s in skipped) + + def test_copy_template_creates_directories( + self, tmp_path: Path, mock_json_handler + ) -> None: + """Subdirectories inside the template are created in the target.""" + _ = mock_json_handler + template = tmp_path / "template" + sub = template / "apps" / "handlers" + sub.mkdir(parents=True) + (sub / "init.py").write_text("# init", encoding="utf-8") + + target = tmp_path / "target" + target.mkdir() + + copied, _skipped = copy_template(template, target, REPLACEMENTS) + + assert (target / "apps" / "handlers").is_dir() + assert (target / "apps" / "handlers" / "init.py").exists() + # Directory entries recorded + assert any("apps/" in c and "(dir)" in c for c in copied) + + def test_copy_template_skips_existing_files( + self, tmp_path: Path, mock_json_handler + ) -> None: + """Existing files in the target directory are not overwritten.""" + _ = mock_json_handler + template = tmp_path / "template" + template.mkdir() + (template / "config.txt").write_text("new content", encoding="utf-8") + + target = tmp_path / "target" + target.mkdir() + (target / "config.txt").write_text("original", encoding="utf-8") + + _copied, skipped = copy_template(template, target, REPLACEMENTS) + + content = (target / "config.txt").read_text(encoding="utf-8") + assert content == "original" + assert any("config.txt" in s and "exists" in s for s in skipped) + + @patch("aipass.spawn.apps.handlers.file_ops.logger") + def test_copy_template_binary_fallback( + self, _mock_logger, tmp_path: Path, mock_json_handler + ) -> None: + """Binary files trigger fallback to shutil.copy2.""" + _ = mock_json_handler + template = tmp_path / "template" + template.mkdir() + binary_data = bytes(range(256)) + (template / "image.bin").write_bytes(binary_data) + + target = tmp_path / "target" + target.mkdir() + + copied, _skipped = copy_template(template, target, REPLACEMENTS) + + result = (target / "image.bin").read_bytes() + assert result == binary_data + assert any("image.bin" in c and "binary" in c for c in copied) + + +# --------------------------------------------------------------------------- +# rename_placeholder_paths +# --------------------------------------------------------------------------- + + +class TestRenamePlaceholderPaths: + """Tests for rename_placeholder_paths().""" + + def test_rename_placeholder_paths_dirs(self, tmp_path: Path) -> None: + """Directories containing {{BRANCH}} in their name are renamed.""" + target = tmp_path / "branch" + (target / "{{BRANCH}}_json").mkdir(parents=True) + + renamed = rename_placeholder_paths(target, "MyAgent") + + assert (target / "myagent_json").is_dir() + assert not (target / "{{BRANCH}}_json").exists() + assert len(renamed) == 1 + assert "myagent_json" in renamed[0] + + def test_rename_placeholder_paths_files(self, tmp_path: Path) -> None: + """Files containing {{BRANCH}} in their name are renamed.""" + target = tmp_path / "branch" + target.mkdir(parents=True) + (target / "{{BRANCH}}_config.json").write_text("{}", encoding="utf-8") + + renamed = rename_placeholder_paths(target, "MyAgent") + + assert (target / "myagent_config.json").exists() + assert not (target / "{{BRANCH}}_config.json").exists() + assert len(renamed) == 1 + + def test_rename_placeholder_paths_no_overwrite( + self, tmp_path: Path + ) -> None: + """If the renamed target already exists, the rename is skipped.""" + target = tmp_path / "branch" + target.mkdir(parents=True) + + # Pre-create the destination + (target / "myagent_data").mkdir() + # Create the placeholder source + (target / "{{BRANCH}}_data").mkdir() + + renamed = rename_placeholder_paths(target, "MyAgent") + + # Both dirs still exist since rename was skipped + assert (target / "myagent_data").is_dir() + assert (target / "{{BRANCH}}_data").is_dir() + assert len(renamed) == 0 + + +# --------------------------------------------------------------------------- +# _walk +# --------------------------------------------------------------------------- + + +class TestWalk: + """Tests for _walk().""" + + def test_walk_yields_all_items(self, tmp_path: Path) -> None: + """_walk yields files and dirs, but skips recursing into .git.""" + (tmp_path / "a.txt").write_text("a", encoding="utf-8") + sub = tmp_path / "subdir" + sub.mkdir() + (sub / "b.txt").write_text("b", encoding="utf-8") + + # .git dir should not be recursed into + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") + + items = list(_walk(tmp_path)) + names = [item.name for item in items] + + assert "a.txt" in names + assert "subdir" in names + assert "b.txt" in names + # .git itself is yielded but not recursed into + assert ".git" in names + # HEAD inside .git should NOT appear + assert "HEAD" not in names + + +# --------------------------------------------------------------------------- +# _should_skip +# --------------------------------------------------------------------------- + + +class TestShouldSkip: + """Tests for _should_skip().""" + + def test_should_skip_pycache(self) -> None: + """__pycache__ in any path component triggers skip.""" + assert _should_skip(Path("__pycache__")) + assert _should_skip(Path("some" / Path("__pycache__") / Path("mod.pyc"))) + + def test_should_skip_normal_file(self) -> None: + """Normal paths are not skipped.""" + assert not _should_skip(Path("apps/handlers/file_ops.py")) + assert not _should_skip(Path("README.md")) + + def test_should_skip_all_skip_names(self) -> None: + """Every entry in SKIP_NAMES triggers a skip.""" + for name in SKIP_NAMES: + assert _should_skip(Path(name)), f"{name} should be skipped" + + +# --------------------------------------------------------------------------- +# _replace_path_placeholders +# --------------------------------------------------------------------------- + + +class TestReplacePathPlaceholders: + """Tests for _replace_path_placeholders().""" + + def test_replace_path_placeholders(self) -> None: + """Placeholder tokens in path components are replaced.""" + rel = Path("{{BRANCH}}_json") / "{{BRANCHNAME}}_config.py" + result = _replace_path_placeholders(rel, REPLACEMENTS) + + assert result == Path("testagent_json") / "TESTAGENT_config.py" + + def test_replace_path_placeholders_no_match(self) -> None: + """Paths without placeholders pass through unchanged.""" + rel = Path("apps") / "handlers" / "init.py" + result = _replace_path_placeholders(rel, REPLACEMENTS) + + assert result == rel + + def test_replace_path_placeholders_empty(self) -> None: + """Single-component path with no parts returns original.""" + rel = Path("file.txt") + result = _replace_path_placeholders(rel, {}) + + assert result == Path("file.txt") + + +# --------------------------------------------------------------------------- +# regenerate_template_registry +# --------------------------------------------------------------------------- + + +class TestRegenerateTemplateRegistry: + """Tests for regenerate_template_registry().""" + + @patch("aipass.spawn.apps.handlers.file_ops.logger") + def test_regenerate_template_registry_creates_json( + self, mock_logger, tmp_path: Path + ) -> None: + """Running regeneration creates .spawn/.template_registry.json.""" + spawn_dir = tmp_path / ".spawn" + spawn_dir.mkdir() + (tmp_path / "hello.txt").write_text("hello world", encoding="utf-8") + + regenerate_template_registry(tmp_path) + + registry_file = spawn_dir / ".template_registry.json" + assert registry_file.exists() + + data = json.loads(registry_file.read_text(encoding="utf-8")) + assert "metadata" in data + assert "files" in data + assert "directories" in data + assert data["metadata"]["generated"] is True + + @patch("aipass.spawn.apps.handlers.file_ops.logger") + def test_regenerate_template_registry_hashes_content( + self, mock_logger, tmp_path: Path + ) -> None: + """SHA-256 hashes in the registry match actual file content.""" + spawn_dir = tmp_path / ".spawn" + spawn_dir.mkdir() + + content = "test content for hashing" + (tmp_path / "hashme.txt").write_text(content, encoding="utf-8") + + regenerate_template_registry(tmp_path) + + registry_file = spawn_dir / ".template_registry.json" + data = json.loads(registry_file.read_text(encoding="utf-8")) + + expected_hash = hashlib.sha256( + content.encode("utf-8") + ).hexdigest()[:16] + + # Find the file entry + files = data["files"] + assert len(files) == 1 + entry = list(files.values())[0] + assert entry["path"] == "hashme.txt" + assert entry["content_hash"] == expected_hash + + @patch("aipass.spawn.apps.handlers.file_ops.logger") + def test_regenerate_template_registry_detects_placeholders( + self, mock_logger, tmp_path: Path + ) -> None: + """Files containing {{BRANCH}} are flagged with has_branch_placeholder.""" + spawn_dir = tmp_path / ".spawn" + spawn_dir.mkdir() + + (tmp_path / "with_placeholder.txt").write_text( + "Name: {{BRANCH}}", encoding="utf-8" + ) + (tmp_path / "no_placeholder.txt").write_text( + "Just plain text", encoding="utf-8" + ) + + regenerate_template_registry(tmp_path) + + registry_file = spawn_dir / ".template_registry.json" + data = json.loads(registry_file.read_text(encoding="utf-8")) + + files_by_name = { + v["name"]: v for v in data["files"].values() + } + + assert files_by_name["with_placeholder.txt"]["has_branch_placeholder"] is True + assert files_by_name["no_placeholder.txt"]["has_branch_placeholder"] is False + + @patch("aipass.spawn.apps.handlers.file_ops.logger") + def test_regenerate_template_registry_skips_spawn_dir( + self, mock_logger, tmp_path: Path + ) -> None: + """.spawn/ internal files are excluded from the registry.""" + spawn_dir = tmp_path / ".spawn" + spawn_dir.mkdir() + (spawn_dir / "internal.json").write_text("{}", encoding="utf-8") + + (tmp_path / "visible.txt").write_text("visible", encoding="utf-8") + + regenerate_template_registry(tmp_path) + + registry_file = spawn_dir / ".template_registry.json" + data = json.loads(registry_file.read_text(encoding="utf-8")) + + all_paths = [v["path"] for v in data["files"].values()] + assert "visible.txt" in all_paths + # No .spawn/ files should appear + assert not any(".spawn" in p for p in all_paths) + + def test_regenerate_template_registry_no_spawn_dir_noop( + self, tmp_path: Path + ) -> None: + """If .spawn/ directory does not exist, function returns early.""" + (tmp_path / "file.txt").write_text("content", encoding="utf-8") + + # Should not raise and should not create anything + regenerate_template_registry(tmp_path) + + assert not (tmp_path / ".spawn").exists() diff --git a/src/aipass/spawn/tests/test_regenerate_registry_ops.py b/src/aipass/spawn/tests/test_regenerate_registry_ops.py new file mode 100644 index 00000000..6211a440 --- /dev/null +++ b/src/aipass/spawn/tests/test_regenerate_registry_ops.py @@ -0,0 +1,540 @@ +# =================== AIPass ==================== +# Name: test_regenerate_registry_ops.py +# Description: Tests for regenerate_registry_ops handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +"""Tests for regenerate_registry_ops: template registry regeneration, ID preservation, scanning.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from aipass.spawn.apps.handlers.regenerate_registry_ops import ( + regenerate_template_registry, + _scan_template_directory, + _next_id, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _make_template(tmp_path: Path, files: dict[str, str] | None = None, dirs: list[str] | None = None) -> Path: + """Create a minimal template directory with given files and sub-directories. + + Args: + tmp_path: Pytest tmp_path root. + files: Mapping of relative path -> file content. + dirs: List of relative directory paths to create (empty dirs). + + Returns: + Path to the template directory. + """ + tpl = tmp_path / "my_template" + tpl.mkdir(parents=True, exist_ok=True) + + for rel, content in (files or {}).items(): + fp = tpl / rel + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content, encoding="utf-8") + + for d in dirs or []: + (tpl / d).mkdir(parents=True, exist_ok=True) + + return tpl + + +def _hash_for(_content: str) -> str: + """Return a deterministic fake 12-char hex hash for the given content key.""" + import hashlib + return hashlib.sha256(_content.encode()).hexdigest()[:12] + + +# ============================================================================= +# regenerate_template_registry — fresh generation +# ============================================================================= + +class TestRegenerateFreshTemplate: + """Test 1 — create template dir with files+dirs, no existing registry.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_fresh_template(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = None + mock_hash.return_value = "aabbccdd1122" + + tpl = _make_template( + tmp_path, + files={"main.py": "print('hi')", "apps/utils.py": "# util"}, + dirs=["apps", "apps/handlers"], + ) + + result = regenerate_template_registry(tpl) + + assert "files" in result + assert "directories" in result + assert "stats" in result + assert "metadata" in result + + # Verify the registry JSON was written to disk + registry_path = tpl / ".spawn" / ".template_registry.json" + assert registry_path.exists() + + persisted = json.loads(registry_path.read_text(encoding="utf-8")) + assert "files" in persisted + assert "directories" in persisted + assert "metadata" in persisted + + +# ============================================================================= +# ID preservation — hash match +# ============================================================================= + +class TestPreservesIdsByHash: + """Test 2 — existing registry with hash->ID, same content at different path.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_preserves_ids_by_hash(self, mock_hash, mock_load, tmp_path): + # Existing registry: f001 lives at "old/main.py" with hash "aabbccdd1122" + mock_load.return_value = { + "files": { + "f001": { + "path": "old/main.py", + "name": "main.py", + "content_hash": "aabbccdd1122", + }, + }, + "directories": {}, + } + # New file produces the same hash but at a different path + mock_hash.return_value = "aabbccdd1122" + + tpl = _make_template(tmp_path, files={"new_location/main.py": "print('hi')"}) + + result = regenerate_template_registry(tpl) + + # The existing ID f001 should be preserved via hash match + assert "f001" in result["files"] + assert result["files"]["f001"]["path"] == "new_location/main.py" + + +# ============================================================================= +# ID preservation — path match +# ============================================================================= + +class TestPreservesIdsByPath: + """Test 3 — same path, different content -> ID preserved via path match.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_preserves_ids_by_path(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = { + "files": { + "f005": { + "path": "config.yaml", + "name": "config.yaml", + "content_hash": "oldoldhash12", + }, + }, + "directories": {}, + } + # New content -> different hash, but same path + mock_hash.return_value = "newnewnew123" + + tpl = _make_template(tmp_path, files={"config.yaml": "new content here"}) + + result = regenerate_template_registry(tpl) + + assert "f005" in result["files"] + assert result["files"]["f005"]["content_hash"] == "newnewnew123" + + +# ============================================================================= +# New ID assignment +# ============================================================================= + +class TestAssignsNewIds: + """Test 4 — file matching neither hash nor path gets a new sequential ID.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_assigns_new_ids(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = { + "files": { + "f001": { + "path": "existing.py", + "name": "existing.py", + "content_hash": "existhash123", + }, + }, + "directories": {}, + } + # Completely new file — different hash and different path + mock_hash.return_value = "brandnew1234" + + tpl = _make_template(tmp_path, files={"totally_new.py": "new stuff"}) + + result = regenerate_template_registry(tpl) + + # f001 is already in the existing registry (not matched), so the new + # file should get f001 since f001 was not claimed by hash or path match + # Actually f001 won't be claimed because the existing file is gone. + # The new file gets f001 as first available. + file_ids = list(result["files"].keys()) + assert len(file_ids) == 1 + assert file_ids[0] == "f001" + + +# ============================================================================= +# Skip __pycache__ +# ============================================================================= + +class TestSkipsPycache: + """Test 5 — __pycache__ directory and its contents should be excluded.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_skips_pycache(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = None + mock_hash.return_value = "abc123def456" + + tpl = _make_template( + tmp_path, + files={ + "main.py": "print(1)", + "__pycache__/main.cpython-312.pyc": "bytecode", + }, + ) + + result = regenerate_template_registry(tpl) + + all_paths = [f["path"] for f in result["files"].values()] + assert not any("__pycache__" in p for p in all_paths) + + all_dir_paths = [d["path"] for d in result["directories"].values()] + assert not any("__pycache__" in p for p in all_dir_paths) + + +# ============================================================================= +# Skip spawn tracking files +# ============================================================================= + +class TestSkipsSpawnTrackingFiles: + """Test 6 — .template_registry.json and .branch_meta.json inside .spawn/ are skipped.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_skips_spawn_tracking_files(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = None + mock_hash.return_value = "aabb11223344" + + tpl = _make_template( + tmp_path, + files={ + "main.py": "print(1)", + ".spawn/.template_registry.json": "{}", + ".spawn/.branch_meta.json": "{}", + }, + ) + + result = regenerate_template_registry(tpl) + + all_paths = [f["path"] for f in result["files"].values()] + assert ".spawn/.template_registry.json" not in all_paths + assert ".spawn/.branch_meta.json" not in all_paths + + +# ============================================================================= +# Allow .spawn/README.md +# ============================================================================= + +class TestAllowsSpawnReadme: + """Test 7 — .spawn/README.md is NOT a tracking file and should be included.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_allows_spawn_readme(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = None + mock_hash.return_value = "readmehash12" + + tpl = _make_template( + tmp_path, + files={ + ".spawn/README.md": "# Spawn docs", + }, + ) + + result = regenerate_template_registry(tpl) + + all_paths = [f["path"] for f in result["files"].values()] + assert ".spawn/README.md" in all_paths + + +# ============================================================================= +# Non-existent directory +# ============================================================================= + +class TestNonexistentDir: + """Test 8 — pass nonexistent path, verify returns error dict.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + def test_regenerate_nonexistent_dir(self, mock_load, tmp_path): + bogus = tmp_path / "does_not_exist" + + result = regenerate_template_registry(bogus) + + assert "error" in result + mock_load.assert_not_called() + + +# ============================================================================= +# Placeholder filename detection +# ============================================================================= + +class TestDetectsPlaceholderFilenames: + """Test 9 — file named {{BRANCH}}.py should have has_branch_placeholder=True.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_detects_placeholder_filenames(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = None + mock_hash.return_value = "placeholder12" + + tpl = _make_template( + tmp_path, + files={ + "{{BRANCH}}.py": "# branch-specific file", + "normal.py": "# regular file", + }, + ) + + result = regenerate_template_registry(tpl) + + # Find the placeholder file + placeholder_entry = None + normal_entry = None + for entry in result["files"].values(): + if "{{BRANCH}}" in entry["path"]: + placeholder_entry = entry + elif entry["path"] == "normal.py": + normal_entry = entry + + assert placeholder_entry is not None + assert placeholder_entry["has_branch_placeholder"] is True + + assert normal_entry is not None + assert normal_entry["has_branch_placeholder"] is False + + +# ============================================================================= +# Directory ID preservation by path +# ============================================================================= + +class TestDirectoryIdPreservation: + """Test 10 — existing dir IDs preserved by path match.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_directory_id_preservation(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = { + "files": {}, + "directories": { + "d010": { + "path": "apps/handlers", + "name": "handlers", + "has_branch_placeholder": False, + }, + }, + } + mock_hash.return_value = "filehash1234" + + tpl = _make_template( + tmp_path, + files={"apps/handlers/main.py": "code"}, + dirs=["apps", "apps/handlers"], + ) + + result = regenerate_template_registry(tpl) + + # d010 should be preserved for "apps/handlers" via path match + assert "d010" in result["directories"] + assert result["directories"]["d010"]["path"] == "apps/handlers" + + +# ============================================================================= +# Directory name fallback +# ============================================================================= + +class TestDirectoryNameFallback: + """Test 11 — dir ID preserved by name when path changes.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_directory_name_fallback(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = { + "files": {}, + "directories": { + "d007": { + "path": "old_location/utils", + "name": "utils", + "has_branch_placeholder": False, + }, + }, + } + mock_hash.return_value = "filehash1234" + + # "utils" now at a different path — should still match by name + tpl = _make_template( + tmp_path, + files={"new_location/utils/helper.py": "code"}, + dirs=["new_location", "new_location/utils"], + ) + + result = regenerate_template_registry(tpl) + + assert "d007" in result["directories"] + assert result["directories"]["d007"]["name"] == "utils" + + +# ============================================================================= +# Stats accuracy +# ============================================================================= + +class TestStatsAccuracy: + """Test 12 — verify stats dict has correct file/dir counts and template_name.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_stats_accuracy(self, mock_hash, mock_load, tmp_path): + mock_load.return_value = { + "files": { + "f001": {"path": "old.py", "name": "old.py", "content_hash": "oldhash12345"}, + "f002": {"path": "old2.py", "name": "old2.py", "content_hash": "oldhash22222"}, + }, + "directories": { + "d001": {"path": "olddir", "name": "olddir"}, + }, + } + mock_hash.return_value = "newhash12345" + + tpl = _make_template( + tmp_path, + files={ + "a.py": "aaa", + "b.py": "bbb", + "c.py": "ccc", + }, + dirs=["subdir1", "subdir2"], + ) + + result = regenerate_template_registry(tpl) + stats = result["stats"] + + assert stats["files_tracked"] == 3 + assert stats["directories_tracked"] == 2 + assert stats["previous_files"] == 2 + assert stats["previous_directories"] == 1 + assert stats["template_name"] == "my_template" + + +# ============================================================================= +# Hash length compatibility (16-char -> 12-char) +# ============================================================================= + +class TestHashLengthCompat: + """Test 13 — existing 16-char hashes match against new 12-char hashes by prefix.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.load_template_registry") + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_regenerate_hash_length_compat(self, mock_hash, mock_load, tmp_path): + # Existing registry has a 16-char hash + mock_load.return_value = { + "files": { + "f042": { + "path": "legacy.py", + "name": "legacy.py", + "content_hash": "aabbccdd11223344", # 16 chars + }, + }, + "directories": {}, + } + # New hash is the 12-char prefix of the old one + mock_hash.return_value = "aabbccdd1122" # 12 chars + + tpl = _make_template(tmp_path, files={"moved/legacy.py": "content"}) + + result = regenerate_template_registry(tpl) + + # ID should be preserved because the 12-char prefix is indexed + assert "f042" in result["files"] + assert result["files"]["f042"]["path"] == "moved/legacy.py" + + +# ============================================================================= +# _next_id tests +# ============================================================================= + +class TestNextId: + """Tests 14-16 — _next_id function.""" + + def test_next_id_basic(self): + """Test 14 — empty claimed set returns prefix + 001.""" + assert _next_id("f", set()) == "f001" + + def test_next_id_skips_claimed(self): + """Test 15 — skips past consecutively claimed IDs.""" + assert _next_id("f", {"f001", "f002"}) == "f003" + + def test_next_id_fills_gaps(self): + """Test 16 — fills gaps in the sequence.""" + assert _next_id("f", {"f001", "f003"}) == "f002" + + def test_next_id_directory_prefix(self): + """Bonus — works with 'd' prefix for directories.""" + assert _next_id("d", set()) == "d001" + assert _next_id("d", {"d001"}) == "d002" + + +# ============================================================================= +# Scan ordering consistency +# ============================================================================= + +class TestScanTemplateDirectoryOrdering: + """Test 17 — files and dirs come from sorted rglob, verify consistent output.""" + + @patch("aipass.spawn.apps.handlers.regenerate_registry_ops.compute_file_hash") + def test_scan_template_directory_ordering(self, mock_hash, tmp_path): + # Return different hashes per file so we can verify ordering + call_count = 0 + + def _sequential_hash(_path): + nonlocal call_count + call_count += 1 + return f"{call_count:012d}" + + mock_hash.side_effect = _sequential_hash + + tpl = _make_template( + tmp_path, + files={ + "z_last.py": "z", + "a_first.py": "a", + "m_middle.py": "m", + }, + dirs=["z_dir", "a_dir"], + ) + + files, directories = _scan_template_directory(tpl, None) + + # Files should have been scanned in sorted order (a_, m_, z_) + file_paths = [entry["path"] for entry in files.values()] + assert file_paths == sorted(file_paths) + + # Directories should also be in sorted order + dir_paths = [entry["path"] for entry in directories.values()] + assert dir_paths == sorted(dir_paths) diff --git a/src/aipass/trigger/tests/test_branch_log_events.py b/src/aipass/trigger/tests/test_branch_log_events.py new file mode 100644 index 00000000..39932436 --- /dev/null +++ b/src/aipass/trigger/tests/test_branch_log_events.py @@ -0,0 +1,424 @@ +"""Tests for the branch_log_events module (apps/modules/branch_log_events.py).""" + +# =================== META ==================== +# Name: test_branch_log_events.py +# Description: Unit tests for branch_log_events module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +import sys +import pytest +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports before branch_log_events module loads.""" + + mock_logger = MagicMock() + + # -- prax logger -------------------------------------------------------- + prax_logger_mod = MagicMock() + prax_logger_mod.system_logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules.logger", prax_logger_mod) + + # -- trigger json handler ----------------------------------------------- + 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) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", json_mod) + + # -- trigger core (trigger object with .fire method) -------------------- + mock_trigger = MagicMock() + mock_trigger.fire = MagicMock() + core_mod = MagicMock() + core_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", core_mod) + + # -- log_watcher handler ------------------------------------------------ + mock_log_watcher = MagicMock() + mock_log_watcher.set_event_callback = MagicMock() + mock_log_watcher.start_branch_log_watcher = MagicMock(return_value=MagicMock()) + mock_log_watcher.stop_branch_log_watcher = MagicMock() + mock_log_watcher.is_branch_log_watcher_active = MagicMock(return_value=False) + mock_log_watcher.get_watcher_status = MagicMock(return_value={ + "active": True, + "watchdog_available": True, + "seen_hashes_count": 0, + "aipass_root": "/fake/path", + }) + mock_log_watcher.clear_seen_hashes = MagicMock() + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.log_watcher", mock_log_watcher) + + # -- trigger config ----------------------------------------------------- + mock_config = MagicMock() + mock_config.TRIGGER_ROOT = "/fake/trigger" + mock_config.AIPASS_PKG_ROOT = "/fake/aipass" + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config) + + # -- CLI console -------------------------------------------------------- + mock_console = MagicMock() + cli_modules = MagicMock() + cli_modules.console = mock_console + monkeypatch.setitem(sys.modules, "aipass.cli", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.cli.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules) + + cli_display = MagicMock() + cli_display.console = mock_console + monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules.display", cli_display) + + # -- rich (for print_help Panel) ---------------------------------------- + monkeypatch.setitem(sys.modules, "rich", MagicMock()) + monkeypatch.setitem(sys.modules, "rich.panel", MagicMock()) + monkeypatch.setitem(sys.modules, "rich.console", MagicMock()) + + # -- Force re-import so mocks take effect ------------------------------- + monkeypatch.delitem(sys.modules, "aipass.trigger.apps.modules.branch_log_events", raising=False) + + +def _import_module(): + """Import branch_log_events module fresh (after mocks are in place).""" + import aipass.trigger.apps.modules.branch_log_events as mod + return mod + + +def _get_log_watcher(): + """Return the mocked log_watcher handler from sys.modules.""" + return sys.modules["aipass.trigger.apps.handlers.log_watcher"] + + +def _get_console(): + """Return the mocked console from sys.modules.""" + return sys.modules["aipass.cli.apps.modules"].console + + +def _get_json_handler(): + """Return the mocked json_handler from sys.modules.""" + return sys.modules["aipass.trigger.apps.handlers.json.json_handler"] + + +def _get_core_trigger(): + """Return the mocked trigger object from sys.modules.""" + return sys.modules["aipass.trigger.apps.modules.core"].trigger + + +def _get_print_str_args(console): + """Extract all string positional arguments passed to console.print(). + + Returns a flat list of strings -- only positional args that are actual + str instances (ignoring MagicMock objects like Panel). + """ + result = [] + for call in console.print.call_args_list: + for arg in call.args: + if isinstance(arg, str): + result.append(arg) + return result + + +# --------------------------------------------------------------------------- +# Tests -- start() +# --------------------------------------------------------------------------- + +def test_start_success_returns_true(): + """start() returns True when start_branch_log_watcher returns an observer.""" + mod = _import_module() + result = mod.start() + assert result is True + + +def test_start_sets_event_callback(): + """start() calls set_event_callback with trigger.fire.""" + mod = _import_module() + mod.start() + watcher = _get_log_watcher() + trigger = _get_core_trigger() + watcher.set_event_callback.assert_called_once_with(trigger.fire) + + +def test_start_calls_start_branch_log_watcher(): + """start() calls start_branch_log_watcher.""" + mod = _import_module() + mod.start() + watcher = _get_log_watcher() + watcher.start_branch_log_watcher.assert_called_once() + + +def test_start_failure_returns_false(): + """start() returns False when start_branch_log_watcher returns None.""" + mod = _import_module() + watcher = _get_log_watcher() + watcher.start_branch_log_watcher.return_value = None + result = mod.start() + assert result is False + + +# --------------------------------------------------------------------------- +# Tests -- stop() +# --------------------------------------------------------------------------- + +def test_stop_calls_stop_branch_log_watcher(): + """stop() calls stop_branch_log_watcher.""" + mod = _import_module() + mod.stop() + watcher = _get_log_watcher() + watcher.stop_branch_log_watcher.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- status() +# --------------------------------------------------------------------------- + +def test_status_returns_dict_from_handler(): + """status() returns the dict from get_watcher_status.""" + mod = _import_module() + result = mod.status() + assert isinstance(result, dict) + assert result["active"] is True + assert result["watchdog_available"] is True + assert result["seen_hashes_count"] == 0 + assert result["aipass_root"] == "/fake/path" + + +def test_status_calls_get_watcher_status(): + """status() delegates to get_watcher_status handler.""" + mod = _import_module() + mod.status() + watcher = _get_log_watcher() + watcher.get_watcher_status.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- reset_hashes() +# --------------------------------------------------------------------------- + +def test_reset_hashes_calls_clear_seen_hashes(): + """reset_hashes() calls clear_seen_hashes.""" + mod = _import_module() + mod.reset_hashes() + watcher = _get_log_watcher() + watcher.clear_seen_hashes.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- handle_command routing +# --------------------------------------------------------------------------- + +def test_handle_command_start_success(): + """handle_command('start', []) starts watcher and returns True.""" + mod = _import_module() + result = mod.handle_command("start", []) + assert result is True + watcher = _get_log_watcher() + watcher.start_branch_log_watcher.assert_called_once() + + +def test_handle_command_start_failure_prints_error(): + """handle_command('start', []) prints failure when watcher fails to start.""" + mod = _import_module() + watcher = _get_log_watcher() + watcher.start_branch_log_watcher.return_value = None + result = mod.handle_command("start", []) + assert result is True + console = _get_console() + printed = _get_print_str_args(console) + assert any("Failed to start" in s for s in printed), ( + f"Expected failure message in printed args: {printed}" + ) + + +def test_handle_command_stop(): + """handle_command('stop', []) stops watcher and returns True.""" + mod = _import_module() + result = mod.handle_command("stop", []) + assert result is True + watcher = _get_log_watcher() + watcher.stop_branch_log_watcher.assert_called_once() + + +def test_handle_command_status(): + """handle_command('status', []) displays status and returns True.""" + mod = _import_module() + result = mod.handle_command("status", []) + assert result is True + watcher = _get_log_watcher() + watcher.get_watcher_status.assert_called_once() + + +def test_handle_command_reset(): + """handle_command('reset', []) clears hashes and returns True.""" + mod = _import_module() + result = mod.handle_command("reset", []) + assert result is True + watcher = _get_log_watcher() + watcher.clear_seen_hashes.assert_called_once() + + +def test_handle_command_logs_operation(): + """handle_command logs the operation via json_handler.""" + mod = _import_module() + mod.handle_command("start", []) + jh = _get_json_handler() + jh.log_operation.assert_called_with("watcher_command", {"command": "start"}) + + +def test_handle_command_unknown_returns_false(): + """handle_command with unrecognized command returns False.""" + mod = _import_module() + result = mod.handle_command("explode", []) + assert result is False + + +# --------------------------------------------------------------------------- +# Tests -- handle_command module-name routing +# --------------------------------------------------------------------------- + +def test_handle_command_module_name_routes_to_subcommand(): + """handle_command('branch_log_events', ['start']) recurses to start.""" + mod = _import_module() + result = mod.handle_command("branch_log_events", ["start"]) + assert result is True + watcher = _get_log_watcher() + watcher.start_branch_log_watcher.assert_called_once() + + +def test_handle_command_module_name_no_args_shows_introspection(): + """handle_command('branch_log_events', []) calls print_introspection.""" + mod = _import_module() + with patch.object(mod, "print_introspection") as mock_intro: + result = mod.handle_command("branch_log_events", []) + assert result is True + mock_intro.assert_called_once() + + +def test_handle_command_module_name_help_flag(): + """handle_command('branch_log_events', ['--help']) calls print_help.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("branch_log_events", ["--help"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_module_name_h_flag(): + """handle_command('branch_log_events', ['-h']) calls print_help.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("branch_log_events", ["-h"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_module_name_help_word(): + """handle_command('branch_log_events', ['help']) calls print_help.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("branch_log_events", ["help"]) + assert result is True + mock_help.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- handle_command help flags on direct subcommands +# --------------------------------------------------------------------------- + +def test_handle_command_subcommand_help_flag(): + """handle_command('start', ['--help']) shows help instead of starting.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("start", ["--help"]) + assert result is True + mock_help.assert_called_once() + watcher = _get_log_watcher() + watcher.start_branch_log_watcher.assert_not_called() + + +def test_handle_command_direct_help_flag(): + """handle_command('--help', []) shows help and returns True.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("--help", []) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_direct_h_flag(): + """handle_command('-h', []) shows help and returns True.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("-h", []) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_direct_help_word(): + """handle_command('help', []) shows help and returns True.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("help", []) + assert result is True + mock_help.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- print_introspection output +# --------------------------------------------------------------------------- + +def test_print_introspection_outputs_module_name(): + """print_introspection prints module name and handler info.""" + mod = _import_module() + mod.print_introspection() + console = _get_console() + printed = _get_print_str_args(console) + output = "\n".join(printed) + assert "branch_log_events Module" in output + assert "Connected Handlers:" in output + assert "log_watcher.py" in output + + +# --------------------------------------------------------------------------- +# Tests -- print_help output +# --------------------------------------------------------------------------- + +def test_print_help_outputs_commands(): + """print_help prints command reference.""" + mod = _import_module() + mod.print_help() + console = _get_console() + printed = _get_print_str_args(console) + output = "\n".join(printed) + assert "start" in output + assert "stop" in output + assert "status" in output + assert "reset" in output + + +# --------------------------------------------------------------------------- +# Tests -- handle_command status output content +# --------------------------------------------------------------------------- + +def test_handle_command_status_prints_all_fields(): + """handle_command('status', []) prints active, watchdog, hashes, root.""" + mod = _import_module() + mod.handle_command("status", []) + console = _get_console() + printed = _get_print_str_args(console) + output = "\n".join(printed) + assert "Active:" in output + assert "Watchdog available:" in output + assert "Seen error hashes:" in output + assert "AIPASS root:" in output diff --git a/src/aipass/trigger/tests/test_error_reporter.py b/src/aipass/trigger/tests/test_error_reporter.py new file mode 100644 index 00000000..6612a588 --- /dev/null +++ b/src/aipass/trigger/tests/test_error_reporter.py @@ -0,0 +1,596 @@ +"""Tests for the error_reporter handler (apps/handlers/error_reporter.py).""" + +# =================== META ==================== +# Name: test_error_reporter.py +# Description: Unit tests for error_reporter handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +import sys +import pytest +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports before error_reporter module loads.""" + + mock_logger = MagicMock() + mock_logger.info = MagicMock() + mock_logger.warning = MagicMock() + + # -- prax logger -------------------------------------------------------- + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + monkeypatch.setitem(sys.modules, "aipass.prax.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules", MagicMock()) + prax_logger_mod = MagicMock() + prax_logger_mod.get_direct_logger = MagicMock(return_value=mock_logger) + prax_logger_mod.system_logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules.logger", prax_logger_mod) + + # -- error_registry ----------------------------------------------------- + mock_registry_report = MagicMock(return_value={ + "id": "test-id-123", + "fingerprint": "abc123def456", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + "error_type": "ImportError", + "message": "No module named foo", + "component": "FLOW", + }) + registry_mod = MagicMock() + registry_mod.report = mock_registry_report + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", registry_mod) + + # -- trigger json handler ----------------------------------------------- + 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) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", json_mod) + + # -- trigger config (needed by error_registry import chain) ------------- + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", MagicMock()) + + # -- Force re-import so mocks take effect ------------------------------- + monkeypatch.delitem(sys.modules, "aipass.trigger.apps.handlers.error_reporter", raising=False) + + +def _import_reporter(): + """Import error_reporter module fresh (after mocks are in place).""" + import aipass.trigger.apps.handlers.error_reporter as mod + return mod + + +def _get_registry_report(): + """Return the mocked registry report function.""" + return sys.modules["aipass.trigger.apps.handlers.error_registry"].report + + +def _get_json_handler(): + """Return the mocked json_handler.""" + return sys.modules["aipass.trigger.apps.handlers.json"].json_handler + + + + +# --------------------------------------------------------------------------- +# Tests -- send_source_fix_email +# --------------------------------------------------------------------------- + +class TestSendSourceFixEmail: + """Tests for the send_source_fix_email function.""" + + def test_successful_send(self, monkeypatch): + """send_source_fix_email returns True when email delivery succeeds.""" + reporter = _import_reporter() + + mock_deliver = MagicMock(return_value=(True, "delivered")) + email_mod = MagicMock() + email_mod.deliver_email_to_branch = mock_deliver + 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", email_mod) + + entry = { + "component": "flow", + "fingerprint": "abc123def456789", + "error_type": "ImportError", + "message": "No module named foo", + "suppress_reason": "Non-critical import", + "log_path": "/var/log/test.log", + "count": 5, + } + result = reporter.send_source_fix_email(entry) + + assert result is True + mock_deliver.assert_called_once() + # Check the recipient was @flow + call_args = mock_deliver.call_args + assert call_args[0][0] == "@flow" + + def test_empty_component_returns_false(self): + """send_source_fix_email returns False when component is empty.""" + reporter = _import_reporter() + + entry = {"component": "", "error_type": "ImportError", "message": "test"} + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_unknown_component_returns_false(self): + """send_source_fix_email returns False when component is 'unknown'.""" + reporter = _import_reporter() + + entry = {"component": "unknown", "error_type": "ImportError", "message": "test"} + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_unknown_component_case_insensitive(self): + """send_source_fix_email returns False for 'UNKNOWN' (case insensitive).""" + reporter = _import_reporter() + + entry = {"component": "UNKNOWN", "error_type": "ImportError", "message": "test"} + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_ai_mail_unavailable_returns_false(self, monkeypatch): + """send_source_fix_email returns False when ai_mail import fails.""" + reporter = _import_reporter() + + # Setting a sys.modules entry to None tells Python the import failed, + # causing ImportError on 'from aipass.ai_mail.apps.modules.email import ...' + monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.modules.email", None) + + entry = { + "component": "flow", + "fingerprint": "abc123", + "error_type": "ImportError", + "message": "test", + } + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_deliver_failure_returns_false(self, monkeypatch): + """send_source_fix_email returns False when deliver_email_to_branch fails.""" + reporter = _import_reporter() + + mock_deliver = MagicMock(return_value=(False, "delivery failed")) + email_mod = MagicMock() + email_mod.deliver_email_to_branch = mock_deliver + 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", email_mod) + + entry = { + "component": "flow", + "fingerprint": "abc123def456789", + "error_type": "ImportError", + "message": "No module named foo", + } + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_deliver_exception_returns_false(self, monkeypatch): + """send_source_fix_email returns False when deliver raises an exception.""" + reporter = _import_reporter() + + mock_deliver = MagicMock(side_effect=RuntimeError("connection refused")) + email_mod = MagicMock() + email_mod.deliver_email_to_branch = mock_deliver + 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", email_mod) + + entry = { + "component": "flow", + "fingerprint": "abc123", + "error_type": "ImportError", + "message": "test", + } + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_missing_component_key_returns_false(self): + """send_source_fix_email returns False when entry has no component key.""" + reporter = _import_reporter() + + entry = {"error_type": "ImportError", "message": "test"} + result = reporter.send_source_fix_email(entry) + + assert result is False + + def test_email_contains_correct_subject(self, monkeypatch): + """The email data contains the correct subject line format.""" + reporter = _import_reporter() + + mock_deliver = MagicMock(return_value=(True, "ok")) + email_mod = MagicMock() + email_mod.deliver_email_to_branch = mock_deliver + 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", email_mod) + + entry = { + "component": "api", + "fingerprint": "abc123def456", + "error_type": "TimeoutError", + "message": "Connection timed out", + } + reporter.send_source_fix_email(entry) + + call_args = mock_deliver.call_args + email_data = call_args[0][1] + assert email_data["subject"] == "[LOG FIX] TimeoutError classified as non-critical" + assert email_data["from"] == "@trigger" + assert email_data["to"] == "@api" + + +# --------------------------------------------------------------------------- +# Tests -- report_error +# --------------------------------------------------------------------------- + +class TestReportError: + """Tests for the report_error function.""" + + def test_new_error_fires_event(self, monkeypatch): + """report_error fires error_detected event when is_new=True.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "new-id", + "fingerprint": "fp123", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error("ImportError", "No module foo", "FLOW") + + mock_trigger.fire.assert_called_once() + assert result["dispatched"] is True + + def test_count_2_fires_event(self, monkeypatch): + """report_error fires event when count==2 (second occurrence).""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "existing-id", + "fingerprint": "fp456", + "is_new": False, + "count": 2, + "first_seen": "2026-04-03 09:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error("ImportError", "No module foo", "FLOW") + + mock_trigger.fire.assert_called_once() + assert result["dispatched"] is True + + def test_count_3_does_not_fire_event(self, monkeypatch): + """report_error does NOT fire event when count>=3 and is_new=False.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "existing-id", + "fingerprint": "fp789", + "is_new": False, + "count": 3, + "first_seen": "2026-04-03 09:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error("ImportError", "No module foo", "FLOW") + + mock_trigger.fire.assert_not_called() + assert result["dispatched"] is False + + def test_count_5_does_not_fire_event(self, monkeypatch): + """report_error does NOT fire event for count=5.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "existing-id", + "fingerprint": "fp999", + "is_new": False, + "count": 5, + "first_seen": "2026-04-03 09:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error("ImportError", "No module foo", "FLOW") + + mock_trigger.fire.assert_not_called() + assert result["dispatched"] is False + + def test_fire_event_false_never_fires(self, monkeypatch): + """report_error with fire_event=False never fires an event.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "new-id", + "fingerprint": "fp000", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error( + "ImportError", "No module foo", "FLOW", fire_event=False + ) + + mock_trigger.fire.assert_not_called() + assert result["dispatched"] is False + + def test_returns_correct_dict_shape(self, monkeypatch): + """report_error returns dict with is_new, count, and dispatched keys.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp111", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error("ImportError", "No module foo", "FLOW") + + assert "is_new" in result + assert "count" in result + assert "dispatched" in result + assert result["is_new"] is True + assert result["count"] == 1 + assert result["dispatched"] is True + + def test_fire_event_exception_sets_dispatched_false(self, monkeypatch): + """report_error sets dispatched=False when trigger.fire raises an exception.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp222", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + mock_trigger.fire.side_effect = RuntimeError("event bus failure") + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + result = reporter.report_error("ImportError", "No module foo", "FLOW") + + assert result["dispatched"] is False + + def test_calls_registry_report_with_correct_args(self): + """report_error calls _registry_report with the correct arguments.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp333", + "is_new": False, + "count": 10, + } + + reporter.report_error( + error_type="TimeoutError", + message="Connection timed out", + component="API", + log_path="/var/log/api.log", + severity="high", + fire_event=False, + ) + + registry_report.assert_called_once_with( + error_type="TimeoutError", + message="Connection timed out", + component="API", + log_path="/var/log/api.log", + severity="high", + ) + + def test_logs_operation_on_successful_dispatch(self, monkeypatch): + """report_error logs the error_reported operation after successful dispatch.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp444", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + reporter.report_error("ImportError", "No module foo", "FLOW") + + jh = _get_json_handler() + jh.log_operation.assert_called_with( + "error_reported", {"branch": "FLOW", "error_type": "ImportError"} + ) + + def test_does_not_log_operation_when_no_dispatch(self): + """report_error does NOT log operation when fire_event=False (early return).""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp555", + "is_new": False, + "count": 10, + } + + reporter.report_error( + "ImportError", "No module foo", "FLOW", fire_event=False + ) + + jh = _get_json_handler() + jh.log_operation.assert_not_called() + + def test_fire_event_passes_all_kwargs(self, monkeypatch): + """report_error passes correct kwargs to trigger.fire.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "unique-id-42", + "fingerprint": "fp666aabbcc", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 09:00:00", + "last_seen": "2026-04-03 09:30:00", + } + + mock_trigger = MagicMock() + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + reporter.report_error( + "ValueError", "bad value", "DRONE", log_path="/logs/drone.log" + ) + + fire_call = mock_trigger.fire.call_args + assert fire_call[0][0] == "error_detected" + assert fire_call[1]["branch"] == "DRONE" + assert fire_call[1]["module"] == "ValueError" + assert fire_call[1]["message"] == "bad value" + assert fire_call[1]["log_path"] == "/logs/drone.log" + assert fire_call[1]["error_hash"] == "unique-id-42" + assert fire_call[1]["fingerprint"] == "fp666aabbcc" + assert fire_call[1]["registry_id"] == "unique-id-42" + assert fire_call[1]["first_seen"] == "2026-04-03 09:00:00" + assert fire_call[1]["last_seen"] == "2026-04-03 09:30:00" + assert fire_call[1]["count"] == 1 + + def test_default_severity_is_medium(self): + """report_error passes severity='medium' by default.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp777", + "is_new": False, + "count": 10, + } + + reporter.report_error("ImportError", "No module foo", "FLOW", fire_event=False) + + registry_report.assert_called_once_with( + error_type="ImportError", + message="No module foo", + component="FLOW", + log_path="", + severity="medium", + ) + + def test_report_error_returns_registry_data(self): + """report_error passes through all registry data in return dict.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "special-id", + "fingerprint": "fp888", + "is_new": False, + "count": 7, + "custom_field": "preserved", + } + + result = reporter.report_error( + "RuntimeError", "something broke", "BACKUP", fire_event=False + ) + + assert result["id"] == "special-id" + assert result["fingerprint"] == "fp888" + assert result["count"] == 7 + assert result["custom_field"] == "preserved" + assert result["dispatched"] is False + + def test_fire_event_failure_still_logs_operation(self, monkeypatch): + """Even when trigger.fire fails, json_handler.log_operation is still called.""" + reporter = _import_reporter() + registry_report = _get_registry_report() + registry_report.return_value = { + "id": "test-id", + "fingerprint": "fp999", + "is_new": True, + "count": 1, + "first_seen": "2026-04-03 10:00:00", + "last_seen": "2026-04-03 10:00:00", + } + + mock_trigger = MagicMock() + mock_trigger.fire.side_effect = RuntimeError("bus down") + trigger_mod = MagicMock() + trigger_mod.trigger = mock_trigger + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", trigger_mod) + + reporter.report_error("ImportError", "No module foo", "FLOW") + + jh = _get_json_handler() + jh.log_operation.assert_called_once() diff --git a/src/aipass/trigger/tests/test_log_events.py b/src/aipass/trigger/tests/test_log_events.py new file mode 100644 index 00000000..8caca056 --- /dev/null +++ b/src/aipass/trigger/tests/test_log_events.py @@ -0,0 +1,340 @@ +"""Tests for the log_events module (apps/modules/log_events.py).""" + +# =================== META ==================== +# Name: test_log_events.py +# Description: Unit tests for log_events module +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +import sys +import pytest +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports before log_events module loads.""" + + mock_logger = MagicMock() + + # -- prax logger -------------------------------------------------------- + prax_logger_mod = MagicMock() + prax_logger_mod.system_logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules.logger", prax_logger_mod) + + # -- trigger json handler ----------------------------------------------- + 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) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", json_mod) + + # -- watchers.log_watcher handler --------------------------------------- + mock_log_watcher = MagicMock() + mock_log_watcher.start_log_watcher = MagicMock(return_value=MagicMock()) + mock_log_watcher.stop_log_watcher = MagicMock() + mock_log_watcher.is_log_watcher_active = MagicMock(return_value=False) + mock_log_watcher.SYSTEM_LOGS_DIR = "/fake/system_logs" + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.watchers", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.watchers.log_watcher", mock_log_watcher) + + # -- trigger config ----------------------------------------------------- + mock_config = MagicMock() + mock_config.TRIGGER_ROOT = "/fake/trigger" + mock_config.AIPASS_PKG_ROOT = "/fake/aipass" + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config) + + # -- CLI console -------------------------------------------------------- + mock_console = MagicMock() + cli_modules = MagicMock() + cli_modules.console = mock_console + monkeypatch.setitem(sys.modules, "aipass.cli", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.cli.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules", cli_modules) + + cli_display = MagicMock() + cli_display.console = mock_console + monkeypatch.setitem(sys.modules, "aipass.cli.apps.modules.display", cli_display) + + # -- rich (for print_help Panel) ---------------------------------------- + monkeypatch.setitem(sys.modules, "rich", MagicMock()) + monkeypatch.setitem(sys.modules, "rich.panel", MagicMock()) + monkeypatch.setitem(sys.modules, "rich.console", MagicMock()) + + # -- Force re-import so mocks take effect ------------------------------- + monkeypatch.delitem(sys.modules, "aipass.trigger.apps.modules.log_events", raising=False) + + +def _import_module(): + """Import log_events module fresh (after mocks are in place).""" + import aipass.trigger.apps.modules.log_events as mod + return mod + + +def _get_log_watcher(): + """Return the mocked watchers.log_watcher handler from sys.modules.""" + return sys.modules["aipass.trigger.apps.handlers.watchers.log_watcher"] + + +def _get_console(): + """Return the mocked console from sys.modules.""" + return sys.modules["aipass.cli.apps.modules"].console + + +def _get_json_handler(): + """Return the mocked json_handler from sys.modules.""" + return sys.modules["aipass.trigger.apps.handlers.json.json_handler"] + + +def _get_print_str_args(console): + """Extract all string positional arguments passed to console.print(). + + Returns a flat list of strings -- only positional args that are actual + str instances (ignoring MagicMock objects like Panel). + """ + result = [] + for call in console.print.call_args_list: + for arg in call.args: + if isinstance(arg, str): + result.append(arg) + return result + + +# --------------------------------------------------------------------------- +# Tests -- start() +# --------------------------------------------------------------------------- + +def test_start_success_returns_true(): + """start() returns True when start_log_watcher returns an observer.""" + mod = _import_module() + result = mod.start() + assert result is True + + +def test_start_calls_start_log_watcher(): + """start() calls start_log_watcher handler.""" + mod = _import_module() + mod.start() + watcher = _get_log_watcher() + watcher.start_log_watcher.assert_called_once() + + +def test_start_failure_returns_false(): + """start() returns False when start_log_watcher returns None.""" + mod = _import_module() + watcher = _get_log_watcher() + watcher.start_log_watcher.return_value = None + result = mod.start() + assert result is False + + +# --------------------------------------------------------------------------- +# Tests -- stop() +# --------------------------------------------------------------------------- + +def test_stop_calls_stop_log_watcher(): + """stop() calls stop_log_watcher handler.""" + mod = _import_module() + mod.stop() + watcher = _get_log_watcher() + watcher.stop_log_watcher.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- status() +# --------------------------------------------------------------------------- + +def test_status_returns_dict_with_correct_shape(): + """status() returns dict with 'active' and 'log_dir' keys.""" + mod = _import_module() + result = mod.status() + assert isinstance(result, dict) + assert "active" in result + assert "log_dir" in result + + +def test_status_active_reflects_handler(): + """status() 'active' value comes from is_log_watcher_active.""" + mod = _import_module() + watcher = _get_log_watcher() + watcher.is_log_watcher_active.return_value = True + result = mod.status() + assert result["active"] is True + + +def test_status_log_dir_is_string(): + """status() 'log_dir' is a string representation of SYSTEM_LOGS_DIR.""" + mod = _import_module() + result = mod.status() + assert result["log_dir"] == "/fake/system_logs" + + +# --------------------------------------------------------------------------- +# Tests -- handle_command routing +# --------------------------------------------------------------------------- + +def test_handle_command_start_success(): + """handle_command('start', []) starts watcher and returns True.""" + mod = _import_module() + result = mod.handle_command("start", []) + assert result is True + watcher = _get_log_watcher() + watcher.start_log_watcher.assert_called_once() + + +def test_handle_command_start_failure_prints_error(): + """handle_command('start', []) prints failure when watcher fails to start.""" + mod = _import_module() + watcher = _get_log_watcher() + watcher.start_log_watcher.return_value = None + result = mod.handle_command("start", []) + assert result is True + console = _get_console() + printed = _get_print_str_args(console) + assert any("Failed to start" in s for s in printed), ( + f"Expected failure message in printed args: {printed}" + ) + + +def test_handle_command_stop(): + """handle_command('stop', []) stops watcher and returns True.""" + mod = _import_module() + result = mod.handle_command("stop", []) + assert result is True + watcher = _get_log_watcher() + watcher.stop_log_watcher.assert_called_once() + + +def test_handle_command_status(): + """handle_command('status', []) displays status and returns True.""" + mod = _import_module() + result = mod.handle_command("status", []) + assert result is True + console = _get_console() + printed = _get_print_str_args(console) + output = "\n".join(printed) + assert "Active:" in output + assert "Log dir:" in output + + +def test_handle_command_logs_operation(): + """handle_command logs the operation via json_handler.""" + mod = _import_module() + mod.handle_command("start", []) + jh = _get_json_handler() + jh.log_operation.assert_called_with("log_watcher_command", {"command": "start"}) + + +def test_handle_command_unknown_returns_false(): + """handle_command with unrecognized command returns False.""" + mod = _import_module() + result = mod.handle_command("explode", []) + assert result is False + + +# --------------------------------------------------------------------------- +# Tests -- handle_command module-name routing +# --------------------------------------------------------------------------- + +def test_handle_command_module_name_routes_to_subcommand(): + """handle_command('log_events', ['start']) recurses to start.""" + mod = _import_module() + result = mod.handle_command("log_events", ["start"]) + assert result is True + watcher = _get_log_watcher() + watcher.start_log_watcher.assert_called_once() + + +def test_handle_command_module_name_no_args_shows_introspection(): + """handle_command('log_events', []) calls print_introspection.""" + mod = _import_module() + with patch.object(mod, "print_introspection") as mock_intro: + result = mod.handle_command("log_events", []) + assert result is True + mock_intro.assert_called_once() + + +def test_handle_command_module_name_help_flag(): + """handle_command('log_events', ['--help']) calls print_help.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("log_events", ["--help"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_module_name_h_flag(): + """handle_command('log_events', ['-h']) calls print_help.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("log_events", ["-h"]) + assert result is True + mock_help.assert_called_once() + + +def test_handle_command_module_name_help_word(): + """handle_command('log_events', ['help']) calls print_help.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("log_events", ["help"]) + assert result is True + mock_help.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests -- handle_command help flags on direct subcommands +# --------------------------------------------------------------------------- + +def test_handle_command_subcommand_help_flag(): + """handle_command('start', ['--help']) shows help instead of starting.""" + mod = _import_module() + with patch.object(mod, "print_help") as mock_help: + result = mod.handle_command("start", ["--help"]) + assert result is True + mock_help.assert_called_once() + watcher = _get_log_watcher() + watcher.start_log_watcher.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests -- print_introspection output +# --------------------------------------------------------------------------- + +def test_print_introspection_outputs_module_name(): + """print_introspection prints module name and handler info.""" + mod = _import_module() + mod.print_introspection() + console = _get_console() + printed = _get_print_str_args(console) + output = "\n".join(printed) + assert "log_events Module" in output + assert "Connected Handlers:" in output + assert "log_watcher.py" in output + + +# --------------------------------------------------------------------------- +# Tests -- print_help output +# --------------------------------------------------------------------------- + +def test_print_help_outputs_commands(): + """print_help prints command reference.""" + mod = _import_module() + mod.print_help() + console = _get_console() + printed = _get_print_str_args(console) + output = "\n".join(printed) + assert "start" in output + assert "stop" in output + assert "status" in output diff --git a/src/aipass/trigger/tests/test_log_watcher.py b/src/aipass/trigger/tests/test_log_watcher.py new file mode 100644 index 00000000..9c7eadb4 --- /dev/null +++ b/src/aipass/trigger/tests/test_log_watcher.py @@ -0,0 +1,512 @@ +"""Tests for the branch log watcher handler (apps/handlers/log_watcher.py).""" + +# =================== META ==================== +# Name: test_log_watcher.py +# Description: Unit tests for branch log watcher event producer +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +import sys +import hashlib +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports before log_watcher module loads.""" + + mock_logger = MagicMock() + mock_logger.info = MagicMock() + mock_logger.warning = MagicMock() + + # -- prax logger -------------------------------------------------------- + prax_logger_mod = MagicMock() + prax_logger_mod.get_direct_logger = MagicMock(return_value=mock_logger) + monkeypatch.setitem(sys.modules, "aipass.prax", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules.logger", prax_logger_mod) + + # -- trigger json handler ----------------------------------------------- + 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) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem( + sys.modules, "aipass.trigger.apps.handlers.json.json_handler", json_mod + ) + + # -- trigger config (TRIGGER_ROOT, AIPASS_PKG_ROOT) --------------------- + mock_config = MagicMock() + mock_config.TRIGGER_ROOT = Path("/tmp/fake_trigger_root") + mock_config.AIPASS_PKG_ROOT = Path("/tmp/fake_aipass_pkg") + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config) + + # -- error_registry (report) ------------------------------------------- + mock_registry = MagicMock() + mock_registry.report = MagicMock(return_value={"is_new": True, "count": 1, "id": "abc123"}) + monkeypatch.setitem( + sys.modules, "aipass.trigger.apps.handlers.error_registry", mock_registry + ) + + # -- watchdog (make it available) --------------------------------------- + mock_observer_cls = MagicMock() + mock_observer_mod = MagicMock() + mock_observer_mod.Observer = mock_observer_cls + monkeypatch.setitem(sys.modules, "watchdog", MagicMock()) + monkeypatch.setitem(sys.modules, "watchdog.observers", mock_observer_mod) + + mock_events_mod = MagicMock() + mock_events_mod.FileSystemEventHandler = type( + "FakeFileSystemEventHandler", (object,), {} + ) + monkeypatch.setitem(sys.modules, "watchdog.events", mock_events_mod) + + # -- Force re-import so mocks take effect ------------------------------- + monkeypatch.delitem( + sys.modules, "aipass.trigger.apps.handlers.log_watcher", raising=False + ) + + +def _import_log_watcher(): + """Import log_watcher module fresh (after mocks are in place).""" + import aipass.trigger.apps.handlers.log_watcher as lw + return lw + + + + +# --------------------------------------------------------------------------- +# Tests -- _generate_error_hash +# --------------------------------------------------------------------------- + +class TestGenerateErrorHash: + """Tests for _generate_error_hash pure function.""" + + def test_deterministic(self): + """Same inputs always produce the same hash.""" + lw = _import_log_watcher() + h1 = lw._generate_error_hash("mod_a", "something broke") + h2 = lw._generate_error_hash("mod_a", "something broke") + assert h1 == h2 + + def test_length_is_8(self): + """Hash is exactly 8 characters long.""" + lw = _import_log_watcher() + h = lw._generate_error_hash("module", "message") + assert len(h) == 8 + + def test_different_inputs_different_hashes(self): + """Different module/message combos produce different hashes.""" + lw = _import_log_watcher() + h1 = lw._generate_error_hash("mod_a", "error one") + h2 = lw._generate_error_hash("mod_b", "error two") + assert h1 != h2 + + def test_matches_md5_prefix(self): + """Hash matches the first 8 chars of MD5(module:message).""" + lw = _import_log_watcher() + expected = hashlib.md5("mymod:mymsg".encode()).hexdigest()[:8] + assert lw._generate_error_hash("mymod", "mymsg") == expected + + +# --------------------------------------------------------------------------- +# Tests -- _detect_branch_from_path +# --------------------------------------------------------------------------- + +class TestDetectBranchFromPath: + """Tests for _detect_branch_from_path.""" + + def test_standard_branch_logs_path(self): + """Detects branch from src/aipass//logs/file.log pattern.""" + lw = _import_log_watcher() + path = "/home/user/src/aipass/flow/logs/flow_planner.log" + assert lw._detect_branch_from_path(path) == "FLOW" + + def test_system_logs_mapped_file(self): + """Uses SYSTEM_LOGS_BRANCH_MAP for known filenames.""" + lw = _import_log_watcher() + path = str(lw.SYSTEM_LOGS_DIR / "telegram_bridge.log") + assert lw._detect_branch_from_path(path) == "API" + + def test_system_logs_prefix_match(self): + """Matches prefix against known branch prefixes for system_logs files.""" + lw = _import_log_watcher() + path = str(lw.SYSTEM_LOGS_DIR / "seedgo_audit.log") + assert lw._detect_branch_from_path(path) == "SEEDGO" + + def test_system_logs_exact_stem_match(self): + """Matches when stem equals a known prefix exactly.""" + lw = _import_log_watcher() + path = str(lw.SYSTEM_LOGS_DIR / "prax.log") + assert lw._detect_branch_from_path(path) == "PRAX" + + def test_unknown_path_returns_unknown(self): + """Returns UNKNOWN for paths that do not match any pattern.""" + lw = _import_log_watcher() + assert lw._detect_branch_from_path("/some/random/path.log") == "UNKNOWN" + + +# --------------------------------------------------------------------------- +# Tests -- _parse_prax_log_line +# --------------------------------------------------------------------------- + +class TestParsePraxLogLine: + """Tests for _parse_prax_log_line.""" + + def test_pipe_format_error(self): + """Parses pipe-separated ERROR line correctly.""" + lw = _import_log_watcher() + line = "2026-03-01 12:00:00.123 | my_module | ERROR | Something failed" + result = lw._parse_prax_log_line(line) + assert result is not None + assert result["level"] == "ERROR" + assert result["module"] == "my_module" + assert result["message"] == "Something failed" + assert "2026-03-01" in result["timestamp"] + + def test_pipe_format_critical(self): + """Parses pipe-separated CRITICAL line correctly.""" + lw = _import_log_watcher() + line = "2026-03-01 12:00:00.123 | core | CRITICAL | Fatal error" + result = lw._parse_prax_log_line(line) + assert result is not None + assert result["level"] == "CRITICAL" + + def test_pipe_format_info_returns_none(self): + """INFO level lines are not returned (only ERROR/CRITICAL).""" + lw = _import_log_watcher() + line = "2026-03-01 12:00:00.123 | my_module | INFO | All good" + assert lw._parse_prax_log_line(line) is None + + def test_pipe_format_warning_returns_none(self): + """WARNING level lines are not returned.""" + lw = _import_log_watcher() + line = "2026-03-01 12:00:00.123 | my_module | WARNING | Watch out" + assert lw._parse_prax_log_line(line) is None + + def test_dash_format_error(self): + """Parses dash-separated ERROR line (Python logging format).""" + lw = _import_log_watcher() + line = "2026-02-10 15:12:29,460 - telegram_bridge - ERROR - Connection lost" + result = lw._parse_prax_log_line(line) + assert result is not None + assert result["level"] == "ERROR" + assert result["module"] == "telegram_bridge" + assert result["message"] == "Connection lost" + + def test_malformed_line_returns_none(self): + """Malformed line that does not match any format returns None.""" + lw = _import_log_watcher() + assert lw._parse_prax_log_line("just some random text") is None + + def test_empty_line_returns_none(self): + """Empty line returns None.""" + lw = _import_log_watcher() + assert lw._parse_prax_log_line("") is None + + +# --------------------------------------------------------------------------- +# Tests -- _is_stale_entry +# --------------------------------------------------------------------------- + +class TestIsStaleEntry: + """Tests for _is_stale_entry.""" + + def test_recent_timestamp_not_stale(self): + """A timestamp within the threshold is NOT stale.""" + lw = _import_log_watcher() + now = datetime.now() + recent = now - timedelta(seconds=10) + ts = recent.strftime("%Y-%m-%d %H:%M:%S.%f") + assert lw._is_stale_entry(ts) is False + + def test_old_timestamp_is_stale(self): + """A timestamp well beyond the threshold IS stale.""" + lw = _import_log_watcher() + old = datetime.now() - timedelta(seconds=600) + ts = old.strftime("%Y-%m-%d %H:%M:%S.%f") + assert lw._is_stale_entry(ts) is True + + def test_unparseable_timestamp_returns_true(self): + """An unparseable timestamp is treated as stale.""" + lw = _import_log_watcher() + assert lw._is_stale_entry("not-a-timestamp") is True + + def test_comma_microsecond_format(self): + """Python logging format with comma microseconds is parsed correctly.""" + lw = _import_log_watcher() + recent = datetime.now() - timedelta(seconds=5) + ts = recent.strftime("%Y-%m-%d %H:%M:%S,") + "123" + assert lw._is_stale_entry(ts) is False + + +# --------------------------------------------------------------------------- +# Tests -- _is_duplicate_error +# --------------------------------------------------------------------------- + +class TestIsDuplicateError: + """Tests for _is_duplicate_error.""" + + def test_first_time_returns_false(self): + """First occurrence of a hash is NOT a duplicate.""" + lw = _import_log_watcher() + lw._seen_error_hashes.clear() + # Patch _save_seen_hashes to avoid disk I/O + with patch.object(lw, "_save_seen_hashes"): + assert lw._is_duplicate_error("aabbccdd") is False + + def test_second_time_returns_true(self): + """Second occurrence of the same hash IS a duplicate.""" + lw = _import_log_watcher() + lw._seen_error_hashes.clear() + with patch.object(lw, "_save_seen_hashes"): + lw._is_duplicate_error("aabbccdd") + assert lw._is_duplicate_error("aabbccdd") is True + + def test_hash_set_size_limit(self): + """When set exceeds MAX_SEEN_HASHES, it is trimmed.""" + lw = _import_log_watcher() + lw._seen_error_hashes.clear() + with patch.object(lw, "_save_seen_hashes"): + # Fill beyond the max + for i in range(lw.MAX_SEEN_HASHES + 10): + lw._is_duplicate_error(f"hash_{i:05d}") + assert len(lw._seen_error_hashes) <= lw.MAX_SEEN_HASHES + + +# --------------------------------------------------------------------------- +# Tests -- set_event_callback / clear_seen_hashes +# --------------------------------------------------------------------------- + +class TestCallbackAndState: + """Tests for set_event_callback and clear_seen_hashes.""" + + def test_set_event_callback_sets_callback(self): + """set_event_callback stores the callback in module-level _fire_event.""" + lw = _import_log_watcher() + cb = MagicMock() + lw.set_event_callback(cb) + assert lw._fire_event is cb + + def test_clear_seen_hashes_empties_set(self): + """clear_seen_hashes empties the _seen_error_hashes set.""" + lw = _import_log_watcher() + lw._seen_error_hashes.add("test_hash") + with patch.object(lw, "_save_seen_hashes"): + lw.clear_seen_hashes() + assert len(lw._seen_error_hashes) == 0 + + +# --------------------------------------------------------------------------- +# Tests -- BranchLogWatcher._should_process +# --------------------------------------------------------------------------- + +class TestShouldProcess: + """Tests for BranchLogWatcher._should_process.""" + + def test_log_file_in_branch_dir_accepted(self): + """.log file inside /aipass/branch/logs/ is accepted.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + assert watcher._should_process("/src/aipass/flow/logs/flow.log") is True + + def test_txt_file_rejected(self): + """.txt file is rejected even if in the right directory.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + assert watcher._should_process("/src/aipass/flow/logs/notes.txt") is False + + def test_excluded_file_rejected(self): + """Excluded log files (e.g. dispatch.log) are rejected.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + assert watcher._should_process("/src/aipass/flow/logs/dispatch.log") is False + + def test_system_logs_accepted(self): + """Log file inside /system_logs/ is accepted.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + assert watcher._should_process("/home/user/system_logs/prax.log") is True + + def test_random_log_outside_known_dirs_rejected(self): + """Log file outside branch and system_logs dirs is rejected.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + assert watcher._should_process("/tmp/random/output.log") is False + + +# --------------------------------------------------------------------------- +# Tests -- BranchLogWatcher._process_log_line +# --------------------------------------------------------------------------- + +class TestProcessLogLine: + """Tests for BranchLogWatcher._process_log_line.""" + + def test_fires_event_on_new_error(self): + """Fires error_detected event for a new ERROR line via registry path.""" + lw = _import_log_watcher() + fire = MagicMock() + lw.set_event_callback(fire) + watcher = lw.BranchLogWatcher() + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + line = f"{now} | my_module | ERROR | Database connection failed" + log_path = "/src/aipass/flow/logs/flow.log" + + watcher._process_log_line(line, log_path) + + fire.assert_called_once() + call_args = fire.call_args + assert call_args[0][0] == "error_detected" + assert call_args[1]["message"] == "Database connection failed" + + def test_skips_semantic_exclusion_patterns(self): + """Lines matching semantic exclusion patterns are skipped.""" + lw = _import_log_watcher() + fire = MagicMock() + lw.set_event_callback(fire) + watcher = lw.BranchLogWatcher() + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + line = f"{now} | handler | ERROR | Processed error error_hash=abc123" + watcher._process_log_line(line, "/src/aipass/flow/logs/flow.log") + + fire.assert_not_called() + + def test_skips_stale_entries(self): + """Lines with stale timestamps are skipped.""" + lw = _import_log_watcher() + fire = MagicMock() + lw.set_event_callback(fire) + watcher = lw.BranchLogWatcher() + + old = (datetime.now() - timedelta(seconds=600)).strftime("%Y-%m-%d %H:%M:%S.%f") + line = f"{old} | mod | ERROR | Old error" + watcher._process_log_line(line, "/src/aipass/flow/logs/flow.log") + + fire.assert_not_called() + + def test_skips_non_error_lines(self): + """INFO-level lines are not processed (parse returns None).""" + lw = _import_log_watcher() + fire = MagicMock() + lw.set_event_callback(fire) + watcher = lw.BranchLogWatcher() + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + line = f"{now} | mod | INFO | All good" + watcher._process_log_line(line, "/src/aipass/flow/logs/flow.log") + + fire.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests -- BranchLogWatcher._read_new_lines +# --------------------------------------------------------------------------- + +class TestReadNewLines: + """Tests for BranchLogWatcher._read_new_lines with tmp_path.""" + + def test_reads_new_content(self, tmp_path): + """Reads only new content appended after initial position.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + + log_file = tmp_path / "test.log" + log_file.write_text("line1\n", encoding="utf-8") + file_path = str(log_file) + + # Set position to end of initial content + watcher.log_positions[file_path] = log_file.stat().st_size + + # Append new content with a fresh ERROR line + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"{now} | mod | ERROR | New failure\n") + + # Patch _save_log_positions to avoid touching the real file + with patch.object(lw, "_save_log_positions"): + watcher._read_new_lines(file_path) + + # Position should have advanced + assert watcher.log_positions[file_path] > 6 # beyond "line1\n" + + def test_handles_log_rotation(self, tmp_path): + """Handles log rotation (file shrinks) by resetting position to 0.""" + lw = _import_log_watcher() + watcher = lw.BranchLogWatcher() + + log_file = tmp_path / "rotated.log" + log_file.write_text("lots of old content here\n", encoding="utf-8") + file_path = str(log_file) + + # Set position beyond current size to simulate rotation + watcher.log_positions[file_path] = 9999 + + # Write new small content + log_file.write_text("short\n", encoding="utf-8") + + with patch.object(lw, "_save_log_positions"): + watcher._read_new_lines(file_path) + + # Position should be at the end of the new content + assert watcher.log_positions[file_path] == log_file.stat().st_size + + +# --------------------------------------------------------------------------- +# Tests -- start / stop / is_active / get_status +# --------------------------------------------------------------------------- + +class TestStartStopStatus: + """Tests for start_branch_log_watcher, stop, is_active, get_watcher_status.""" + + def test_start_returns_none_when_watchdog_unavailable(self): + """start_branch_log_watcher returns None when WATCHDOG_AVAILABLE is False.""" + lw = _import_log_watcher() + lw.WATCHDOG_AVAILABLE = False + result = lw.start_branch_log_watcher() + assert result is None + + def test_is_branch_log_watcher_active_returns_false_when_not_started(self): + """is_branch_log_watcher_active returns False when no observer is set.""" + lw = _import_log_watcher() + lw._branch_log_observer = None + assert lw.is_branch_log_watcher_active() is False + + def test_get_watcher_status_returns_correct_shape(self): + """get_watcher_status returns a dict with all expected keys.""" + lw = _import_log_watcher() + status = lw.get_watcher_status() + assert isinstance(status, dict) + expected_keys = { + "active", + "watchdog_available", + "seen_hashes_count", + "tracked_log_files", + "excluded_files", + "stale_threshold_seconds", + "aipass_root", + } + assert expected_keys == set(status.keys()) + + def test_get_watcher_status_values(self): + """get_watcher_status returns sensible values.""" + lw = _import_log_watcher() + status = lw.get_watcher_status() + assert status["stale_threshold_seconds"] == 300 + assert isinstance(status["excluded_files"], list) + assert len(status["excluded_files"]) > 0 diff --git a/src/aipass/trigger/tests/test_medic_state.py b/src/aipass/trigger/tests/test_medic_state.py new file mode 100644 index 00000000..11a3fd0a --- /dev/null +++ b/src/aipass/trigger/tests/test_medic_state.py @@ -0,0 +1,496 @@ +"""Tests for the medic_state handler (apps/handlers/medic_state.py).""" + +# =================== META ==================== +# Name: test_medic_state.py +# Description: Unit tests for medic_state handler — real file I/O via tmp_path +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +import json +import sys +import pytest +from pathlib import Path +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports before medic_state module loads.""" + + mock_logger = MagicMock() + + # -- prax logger -------------------------------------------------------- + prax_logger_mod = MagicMock() + prax_logger_mod.get_direct_logger = MagicMock(return_value=mock_logger) + prax_logger_mod.system_logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules.logger", prax_logger_mod) + + # -- trigger json handler ----------------------------------------------- + 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) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", json_mod) + + # -- trigger config (TRIGGER_ROOT) -------------------------------------- + config_mod = MagicMock() + config_mod.TRIGGER_ROOT = Path("/tmp/fake_trigger_root") + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", config_mod) + + # -- Force re-import so mocks take effect ------------------------------- + monkeypatch.delitem(sys.modules, "aipass.trigger.apps.handlers.medic_state", raising=False) + + +@pytest.fixture +def state_mod(tmp_path, monkeypatch): + """Import medic_state and point all file path constants to tmp_path.""" + import aipass.trigger.apps.handlers.medic_state as mod + + config_file = tmp_path / "trigger_json" / "trigger_config.json" + suppressed_log = tmp_path / "logs" / "medic_suppressed.log" + rate_limited_log = tmp_path / "logs" / "rate_limited.log" + + monkeypatch.setattr(mod, "TRIGGER_CONFIG_FILE", config_file) + monkeypatch.setattr(mod, "MEDIC_SUPPRESSED_LOG", suppressed_log) + monkeypatch.setattr(mod, "RATE_LIMITED_LOG", rate_limited_log) + + return mod + + +def _get_json_handler(): + """Return the mocked json_handler.""" + return sys.modules["aipass.trigger.apps.handlers.json"].json_handler + + +# --------------------------------------------------------------------------- +# Tests -- read_config +# --------------------------------------------------------------------------- + +class TestReadConfig: + """Tests for read_config.""" + + def test_read_config_file_exists(self, state_mod): + """read_config returns parsed dict when file exists.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + data = {"config": {"medic_enabled": True}, "version": "1.0"} + config_file.write_text(json.dumps(data), encoding="utf-8") + + result = state_mod.read_config() + + assert result == data + assert result["config"]["medic_enabled"] is True + + def test_read_config_file_missing_returns_empty(self, state_mod): + """read_config returns empty dict when file does not exist.""" + result = state_mod.read_config() + + assert result == {} + + def test_read_config_corrupt_file_returns_empty(self, state_mod): + """read_config returns empty dict when file contains invalid JSON.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text("not valid json {{{", encoding="utf-8") + + result = state_mod.read_config() + + assert result == {} + + +# --------------------------------------------------------------------------- +# Tests -- write_config +# --------------------------------------------------------------------------- + +class TestWriteConfig: + """Tests for write_config.""" + + def test_write_config_creates_file(self, state_mod): + """write_config creates the config file with valid JSON.""" + data = {"config": {"medic_enabled": False}} + + result = state_mod.write_config(data) + + assert result is True + config_file = state_mod.TRIGGER_CONFIG_FILE + assert config_file.exists() + written = json.loads(config_file.read_text(encoding="utf-8")) + assert written == data + + def test_write_config_creates_parent_dirs(self, state_mod): + """write_config creates parent directories when they do not exist.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + assert not config_file.parent.exists() + + result = state_mod.write_config({"test": True}) + + assert result is True + assert config_file.parent.exists() + + +# --------------------------------------------------------------------------- +# Tests -- is_enabled +# --------------------------------------------------------------------------- + +class TestIsEnabled: + """Tests for is_enabled.""" + + def test_is_enabled_default_true(self, state_mod): + """is_enabled returns True when config file is missing (default).""" + result = state_mod.is_enabled() + + assert result is True + + def test_is_enabled_true_when_set(self, state_mod): + """is_enabled returns True when medic_enabled is True.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps({"config": {"medic_enabled": True}}), encoding="utf-8" + ) + + result = state_mod.is_enabled() + + assert result is True + + def test_is_enabled_false_when_disabled(self, state_mod): + """is_enabled returns False when medic_enabled is False.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps({"config": {"medic_enabled": False}}), encoding="utf-8" + ) + + result = state_mod.is_enabled() + + assert result is False + + def test_is_enabled_true_when_config_empty(self, state_mod): + """is_enabled returns True when config has no medic_enabled key.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps({"config": {}}), encoding="utf-8") + + result = state_mod.is_enabled() + + assert result is True + + +# --------------------------------------------------------------------------- +# Tests -- set_enabled +# --------------------------------------------------------------------------- + +class TestSetEnabled: + """Tests for set_enabled.""" + + def test_set_enabled_true(self, state_mod): + """set_enabled(True) persists medic_enabled=True to disk.""" + result = state_mod.set_enabled(True) + + assert result is True + config_file = state_mod.TRIGGER_CONFIG_FILE + data = json.loads(config_file.read_text(encoding="utf-8")) + assert data["config"]["medic_enabled"] is True + + def test_set_enabled_false(self, state_mod): + """set_enabled(False) persists medic_enabled=False to disk.""" + result = state_mod.set_enabled(False) + + assert result is True + config_file = state_mod.TRIGGER_CONFIG_FILE + data = json.loads(config_file.read_text(encoding="utf-8")) + assert data["config"]["medic_enabled"] is False + + def test_set_enabled_toggle_round_trip(self, state_mod): + """set_enabled toggles correctly across multiple calls.""" + state_mod.set_enabled(False) + assert state_mod.is_enabled() is False + + state_mod.set_enabled(True) + assert state_mod.is_enabled() is True + + def test_set_enabled_logs_operation(self, state_mod): + """set_enabled logs the state_persisted operation via json_handler.""" + state_mod.set_enabled(True) + + jh = _get_json_handler() + jh.log_operation.assert_called_with( + "state_persisted", {"key": "medic_enabled", "value": True} + ) + + def test_set_enabled_preserves_existing_config(self, state_mod): + """set_enabled preserves other config keys when updating.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps({"config": {"medic_enabled": True, "other_key": "keep_me"}}), + encoding="utf-8", + ) + + state_mod.set_enabled(False) + + data = json.loads(config_file.read_text(encoding="utf-8")) + assert data["config"]["medic_enabled"] is False + assert data["config"]["other_key"] == "keep_me" + + +# --------------------------------------------------------------------------- +# Tests -- _normalize_branch_name +# --------------------------------------------------------------------------- + +class TestNormalizeBranchName: + """Tests for _normalize_branch_name.""" + + def test_strips_at_prefix(self, state_mod): + """_normalize_branch_name strips the @ prefix.""" + assert state_mod._normalize_branch_name("@speakeasy") == "speakeasy" + + def test_extracts_from_path(self, state_mod): + """_normalize_branch_name extracts the last path component.""" + assert state_mod._normalize_branch_name("src/aipass/flow") == "flow" + + def test_lowercases(self, state_mod): + """_normalize_branch_name lowercases the result.""" + assert state_mod._normalize_branch_name("@DRONE") == "drone" + + def test_combined_at_and_path(self, state_mod): + """_normalize_branch_name handles @ with path (strips @, extracts name).""" + result = state_mod._normalize_branch_name("@src/aipass/Api") + assert result == "api" + + def test_plain_name(self, state_mod): + """_normalize_branch_name passes through a plain lowercase name.""" + assert state_mod._normalize_branch_name("trigger") == "trigger" + + +# --------------------------------------------------------------------------- +# Tests -- get_muted_branches +# --------------------------------------------------------------------------- + +class TestGetMutedBranches: + """Tests for get_muted_branches.""" + + def test_empty_list_when_no_config(self, state_mod): + """get_muted_branches returns empty list when config is missing.""" + result = state_mod.get_muted_branches() + + assert result == [] + + def test_returns_populated_list(self, state_mod): + """get_muted_branches returns the stored muted branch names.""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps({"config": {"muted_branches": ["speakeasy", "api"]}}), + encoding="utf-8", + ) + + result = state_mod.get_muted_branches() + + assert result == ["speakeasy", "api"] + + def test_normalizes_branch_names(self, state_mod): + """get_muted_branches normalizes names (strips @, lowercases).""" + config_file = state_mod.TRIGGER_CONFIG_FILE + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps({"config": {"muted_branches": ["@SPEAKEASY", "@Api"]}}), + encoding="utf-8", + ) + + result = state_mod.get_muted_branches() + + assert result == ["speakeasy", "api"] + + +# --------------------------------------------------------------------------- +# Tests -- mute_branch +# --------------------------------------------------------------------------- + +class TestMuteBranch: + """Tests for mute_branch.""" + + def test_adds_new_branch(self, state_mod): + """mute_branch adds a new branch to the muted list.""" + result = state_mod.mute_branch("speakeasy") + + assert result is True + muted = state_mod.get_muted_branches() + assert "speakeasy" in muted + + def test_does_not_duplicate(self, state_mod): + """mute_branch does not add a branch that is already muted.""" + state_mod.mute_branch("speakeasy") + state_mod.mute_branch("speakeasy") + + muted = state_mod.get_muted_branches() + assert muted.count("speakeasy") == 1 + + def test_handles_at_prefix(self, state_mod): + """mute_branch correctly handles names with @ prefix.""" + state_mod.mute_branch("@flow") + + muted = state_mod.get_muted_branches() + assert "flow" in muted + + def test_multiple_branches(self, state_mod): + """mute_branch can accumulate multiple branches.""" + state_mod.mute_branch("speakeasy") + state_mod.mute_branch("api") + state_mod.mute_branch("drone") + + muted = state_mod.get_muted_branches() + assert set(muted) == {"speakeasy", "api", "drone"} + + +# --------------------------------------------------------------------------- +# Tests -- unmute_branch +# --------------------------------------------------------------------------- + +class TestUnmuteBranch: + """Tests for unmute_branch.""" + + def test_removes_muted_branch(self, state_mod): + """unmute_branch removes a previously muted branch.""" + state_mod.mute_branch("speakeasy") + state_mod.mute_branch("api") + + result = state_mod.unmute_branch("speakeasy") + + assert result is True + muted = state_mod.get_muted_branches() + assert "speakeasy" not in muted + assert "api" in muted + + def test_handles_nonexistent_branch(self, state_mod): + """unmute_branch returns True even when branch is not in muted list.""" + result = state_mod.unmute_branch("nonexistent") + + assert result is True + + def test_handles_at_prefix(self, state_mod): + """unmute_branch correctly handles names with @ prefix.""" + state_mod.mute_branch("flow") + + result = state_mod.unmute_branch("@flow") + + assert result is True + muted = state_mod.get_muted_branches() + assert "flow" not in muted + + +# --------------------------------------------------------------------------- +# Tests -- get_suppression_stats +# --------------------------------------------------------------------------- + +class TestGetSuppressionStats: + """Tests for get_suppression_stats.""" + + def test_no_log_file(self, state_mod): + """get_suppression_stats returns zeros when log file does not exist.""" + result = state_mod.get_suppression_stats() + + assert result["suppressed_count"] == 0 + assert result["last_suppressed"] == "never" + + def test_empty_log_file(self, state_mod): + """get_suppression_stats returns zeros when log file is empty.""" + log_file = state_mod.MEDIC_SUPPRESSED_LOG + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text("", encoding="utf-8") + + result = state_mod.get_suppression_stats() + + assert result["suppressed_count"] == 0 + assert result["last_suppressed"] == "never" + + def test_populated_log(self, state_mod): + """get_suppression_stats parses log lines and returns correct stats.""" + log_file = state_mod.MEDIC_SUPPRESSED_LOG + log_file.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "2026-04-01 10:00:00 | ImportError | FLOW", + "2026-04-02 11:00:00 | TimeoutError | API", + "2026-04-03 12:00:00 | ValueError | DRONE", + ] + log_file.write_text("\n".join(lines), encoding="utf-8") + + result = state_mod.get_suppression_stats() + + assert result["suppressed_count"] == 3 + assert result["last_suppressed"] == "2026-04-03 12:00:00" + + def test_log_line_without_pipe_separator(self, state_mod): + """get_suppression_stats returns 'unknown' when last line has no pipe.""" + log_file = state_mod.MEDIC_SUPPRESSED_LOG + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text("malformed line without pipe", encoding="utf-8") + + result = state_mod.get_suppression_stats() + + assert result["suppressed_count"] == 1 + assert result["last_suppressed"] == "unknown" + + +# --------------------------------------------------------------------------- +# Tests -- get_rate_limit_stats +# --------------------------------------------------------------------------- + +class TestGetRateLimitStats: + """Tests for get_rate_limit_stats.""" + + def test_no_log_file(self, state_mod): + """get_rate_limit_stats returns zeros when log file does not exist.""" + result = state_mod.get_rate_limit_stats() + + assert result["rate_limited_count"] == 0 + assert result["last_rate_limited"] == "never" + + def test_empty_log_file(self, state_mod): + """get_rate_limit_stats returns zeros when log file is empty.""" + log_file = state_mod.RATE_LIMITED_LOG + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text("", encoding="utf-8") + + result = state_mod.get_rate_limit_stats() + + assert result["rate_limited_count"] == 0 + assert result["last_rate_limited"] == "never" + + def test_populated_log(self, state_mod): + """get_rate_limit_stats parses log lines and returns correct stats.""" + log_file = state_mod.RATE_LIMITED_LOG + log_file.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "2026-04-01 08:00:00 | ImportError | fp123", + "2026-04-02 09:00:00 | TimeoutError | fp456", + ] + log_file.write_text("\n".join(lines), encoding="utf-8") + + result = state_mod.get_rate_limit_stats() + + assert result["rate_limited_count"] == 2 + assert result["last_rate_limited"] == "2026-04-02 09:00:00" + + def test_log_line_without_pipe_separator(self, state_mod): + """get_rate_limit_stats returns 'unknown' when last line has no pipe.""" + log_file = state_mod.RATE_LIMITED_LOG + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text("malformed line", encoding="utf-8") + + result = state_mod.get_rate_limit_stats() + + assert result["rate_limited_count"] == 1 + assert result["last_rate_limited"] == "unknown" diff --git a/src/aipass/trigger/tests/test_watchers_log_watcher.py b/src/aipass/trigger/tests/test_watchers_log_watcher.py new file mode 100644 index 00000000..9d10c7c3 --- /dev/null +++ b/src/aipass/trigger/tests/test_watchers_log_watcher.py @@ -0,0 +1,350 @@ +"""Tests for the centralized system_logs watcher (apps/handlers/watchers/log_watcher.py).""" + +# =================== META ==================== +# Name: test_watchers_log_watcher.py +# Description: Unit tests for centralized system_logs log watcher +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +import sys +import hashlib +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _mock_infrastructure(monkeypatch): + """Mock heavy infrastructure imports before watchers/log_watcher loads.""" + + mock_logger = MagicMock() + mock_logger.info = MagicMock() + mock_logger.warning = MagicMock() + + # -- prax logger (imported as `from aipass.prax import logger`) ---------- + prax_mod = MagicMock() + prax_mod.logger = mock_logger + monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod) + monkeypatch.setitem(sys.modules, "aipass.prax.apps", MagicMock()) + monkeypatch.setitem(sys.modules, "aipass.prax.apps.modules", MagicMock()) + monkeypatch.setitem( + sys.modules, "aipass.prax.apps.modules.logger", MagicMock() + ) + + # -- trigger json handler ----------------------------------------------- + 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) + json_mod = MagicMock() + json_mod.log_operation = mock_json_handler.log_operation + monkeypatch.setitem( + sys.modules, "aipass.trigger.apps.handlers.json.json_handler", json_mod + ) + + # -- trigger config (TRIGGER_ROOT) -------------------------------------- + mock_config = MagicMock() + mock_config.TRIGGER_ROOT = Path("/tmp/fake_trigger_root") + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config) + + # -- trigger core (trigger.fire) ---------------------------------------- + mock_trigger_obj = MagicMock() + mock_core = MagicMock() + mock_core.trigger = mock_trigger_obj + monkeypatch.setitem(sys.modules, "aipass.trigger.apps.modules.core", mock_core) + + # -- watchdog (make it available) --------------------------------------- + mock_observer_cls = MagicMock() + mock_observer_mod = MagicMock() + mock_observer_mod.Observer = mock_observer_cls + monkeypatch.setitem(sys.modules, "watchdog", MagicMock()) + monkeypatch.setitem(sys.modules, "watchdog.observers", mock_observer_mod) + + mock_events_mod = MagicMock() + mock_events_mod.FileSystemEventHandler = type( + "FakeFileSystemEventHandler", (object,), {} + ) + monkeypatch.setitem(sys.modules, "watchdog.events", mock_events_mod) + + # -- Force re-import so mocks take effect ------------------------------- + monkeypatch.delitem( + sys.modules, + "aipass.trigger.apps.handlers.watchers.log_watcher", + raising=False, + ) + + +def _import_watchers_lw(): + """Import watchers/log_watcher module fresh (after mocks are in place).""" + import aipass.trigger.apps.handlers.watchers.log_watcher as wlw + return wlw + + + + +# --------------------------------------------------------------------------- +# Tests -- _generate_error_hash +# --------------------------------------------------------------------------- + +class TestGenerateErrorHash: + """Tests for _generate_error_hash pure function.""" + + def test_deterministic(self): + """Same inputs always produce the same hash.""" + wlw = _import_watchers_lw() + h1 = wlw._generate_error_hash("mod_a", "something broke") + h2 = wlw._generate_error_hash("mod_a", "something broke") + assert h1 == h2 + + def test_length_is_8(self): + """Hash is exactly 8 characters long.""" + wlw = _import_watchers_lw() + h = wlw._generate_error_hash("module", "message") + assert len(h) == 8 + + def test_matches_md5_prefix(self): + """Hash matches the first 8 chars of MD5(module:message).""" + wlw = _import_watchers_lw() + expected = hashlib.md5("mymod:mymsg".encode()).hexdigest()[:8] + assert wlw._generate_error_hash("mymod", "mymsg") == expected + + def test_different_inputs_different_hashes(self): + """Different inputs produce different hashes.""" + wlw = _import_watchers_lw() + h1 = wlw._generate_error_hash("mod_a", "err one") + h2 = wlw._generate_error_hash("mod_b", "err two") + assert h1 != h2 + + +# --------------------------------------------------------------------------- +# Tests -- _detect_branch_from_log +# --------------------------------------------------------------------------- + +class TestDetectBranchFromLog: + """Tests for _detect_branch_from_log.""" + + def test_branch_module_pattern(self): + """seedgo_audit.log returns SEEDGO.""" + wlw = _import_watchers_lw() + assert wlw._detect_branch_from_log("seedgo_audit.log") == "SEEDGO" + + def test_simple_log(self): + """simple.log returns SIMPLE.""" + wlw = _import_watchers_lw() + assert wlw._detect_branch_from_log("simple.log") == "SIMPLE" + + def test_full_path(self): + """Works with a full path, not just filename.""" + wlw = _import_watchers_lw() + assert wlw._detect_branch_from_log("/var/logs/trigger_events.log") == "TRIGGER" + + def test_multiple_underscores(self): + """ai_mail_dispatch.log returns AI (first part before underscore).""" + wlw = _import_watchers_lw() + assert wlw._detect_branch_from_log("ai_mail_dispatch.log") == "AI" + + +# --------------------------------------------------------------------------- +# Tests -- _detect_log_level +# --------------------------------------------------------------------------- + +class TestDetectLogLevel: + """Tests for _detect_log_level.""" + + def test_error_dash_format(self): + """Detects ERROR from dash-separated format.""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("2026-01-01 - mod - ERROR - msg") == "error" + + def test_error_space_format(self): + """Detects ERROR from space-separated format.""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("2026-01-01 ERROR something") == "error" + + def test_error_bracket_format(self): + """Detects ERROR from bracket format [ERROR].""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("[ERROR] something happened") == "error" + + def test_warning(self): + """Detects WARNING level.""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("2026-01-01 - mod - WARNING - msg") == "warning" + + def test_critical_maps_to_error(self): + """CRITICAL level maps to error.""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("2026-01-01 - mod - CRITICAL - msg") == "error" + + def test_debug(self): + """Detects DEBUG level.""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("2026-01-01 - mod - DEBUG - msg") == "debug" + + def test_info_default(self): + """Lines without a recognized level default to info.""" + wlw = _import_watchers_lw() + assert wlw._detect_log_level("just a plain log message") == "info" + + +# --------------------------------------------------------------------------- +# Tests -- _parse_log_message +# --------------------------------------------------------------------------- + +class TestParseLogMessage: + """Tests for _parse_log_message.""" + + def test_pipe_format_extracts_message(self): + """Extracts message from pipe-separated format.""" + wlw = _import_watchers_lw() + line = "2026-01-01 | mod | ERROR | Connection refused" + assert wlw._parse_log_message(line) == "Connection refused" + + def test_pipe_format_with_pipes_in_message(self): + """Handles messages that contain pipe characters.""" + wlw = _import_watchers_lw() + line = "ts | mod | ERROR | a | b | c" + assert wlw._parse_log_message(line) == "a | b | c" + + def test_non_pipe_returns_stripped_line(self): + """Non-pipe line is returned stripped.""" + wlw = _import_watchers_lw() + assert wlw._parse_log_message(" just a message ") == "just a message" + + +# --------------------------------------------------------------------------- +# Tests -- _extract_module_name +# --------------------------------------------------------------------------- + +class TestExtractModuleName: + """Tests for _extract_module_name.""" + + def test_pipe_format_extracts_module(self): + """Extracts module from second pipe-separated field.""" + wlw = _import_watchers_lw() + line = "2026-01-01 | my_module | ERROR | msg" + assert wlw._extract_module_name(line) == "my_module" + + def test_non_pipe_returns_unknown(self): + """Non-pipe line returns 'unknown'.""" + wlw = _import_watchers_lw() + assert wlw._extract_module_name("no pipes here") == "unknown" + + +# --------------------------------------------------------------------------- +# Tests -- _should_skip_log +# --------------------------------------------------------------------------- + +class TestShouldSkipLog: + """Tests for _should_skip_log.""" + + def test_initialization_line_skipped(self): + """Initialization noise is skipped.""" + wlw = _import_watchers_lw() + assert wlw._should_skip_log("Initializing trigger module") is True + + def test_module_initialized_skipped(self): + """'Module initialized' line is skipped.""" + wlw = _import_watchers_lw() + assert wlw._should_skip_log("Module initialized successfully") is True + + def test_configuration_loaded_skipped(self): + """'Configuration loaded' line is skipped.""" + wlw = _import_watchers_lw() + assert wlw._should_skip_log("Configuration loaded from config.json") is True + + def test_real_error_not_skipped(self): + """Actual error messages are NOT skipped.""" + wlw = _import_watchers_lw() + assert wlw._should_skip_log("Database connection failed") is False + + def test_cleanup_zero_skipped(self): + """'Cleanup completed - Removed 0' noise line is skipped.""" + wlw = _import_watchers_lw() + assert wlw._should_skip_log("Cleanup completed - Removed 0 entries") is True + + +# --------------------------------------------------------------------------- +# Tests -- LogFileWatcher._read_new_lines +# --------------------------------------------------------------------------- + +class TestLogFileWatcherReadNewLines: + """Tests for LogFileWatcher._read_new_lines with tmp_path.""" + + def test_reads_new_content(self, tmp_path): + """Reads only new content appended after initial position.""" + wlw = _import_watchers_lw() + watcher = wlw.LogFileWatcher() + + log_file = tmp_path / "test.log" + log_file.write_text("initial line\n", encoding="utf-8") + file_path = str(log_file) + + # Set position to end of initial content + watcher.log_positions[file_path] = log_file.stat().st_size + + # Append new error content + with open(log_file, "a", encoding="utf-8") as f: + f.write("2026-01-01 | mod | ERROR | New error\n") + + watcher._read_new_lines(file_path) + + # Position should have advanced past new content + assert watcher.log_positions[file_path] == log_file.stat().st_size + + def test_no_change_no_read(self, tmp_path): + """When file has not changed since last position, nothing is read.""" + wlw = _import_watchers_lw() + watcher = wlw.LogFileWatcher() + + log_file = tmp_path / "unchanged.log" + log_file.write_text("line\n", encoding="utf-8") + file_path = str(log_file) + watcher.log_positions[file_path] = log_file.stat().st_size + + # Patch _process_log_line to verify it is NOT called + with patch.object(watcher, "_process_log_line") as mock_proc: + watcher._read_new_lines(file_path) + mock_proc.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests -- start / stop / is_active +# --------------------------------------------------------------------------- + +class TestStartStopActive: + """Tests for start_log_watcher, stop_log_watcher, is_log_watcher_active.""" + + def test_start_returns_none_when_watchdog_unavailable(self): + """start_log_watcher returns None when WATCHDOG_AVAILABLE is False.""" + wlw = _import_watchers_lw() + wlw.WATCHDOG_AVAILABLE = False + assert wlw.start_log_watcher() is None + + def test_start_returns_none_when_dir_missing(self, tmp_path): + """start_log_watcher returns None when SYSTEM_LOGS_DIR does not exist.""" + wlw = _import_watchers_lw() + wlw.SYSTEM_LOGS_DIR = tmp_path / "nonexistent" + assert wlw.start_log_watcher() is None + + def test_is_log_watcher_active_false_when_not_started(self): + """is_log_watcher_active returns False when no observer is set.""" + wlw = _import_watchers_lw() + wlw._log_observer = None + assert wlw.is_log_watcher_active() is False + + def test_is_log_watcher_active_false_when_observer_dead(self): + """is_log_watcher_active returns False when observer is not alive.""" + wlw = _import_watchers_lw() + mock_obs = MagicMock() + mock_obs.is_alive.return_value = False + wlw._log_observer = mock_obs + assert wlw.is_log_watcher_active() is False diff --git a/src/commons/tests/test_comments_posts.py b/src/commons/tests/test_comments_posts.py new file mode 100644 index 00000000..7f6b3007 --- /dev/null +++ b/src/commons/tests/test_comments_posts.py @@ -0,0 +1,714 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_comments_posts.py - Comment and Post Operations Tests +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: commons/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-04-03): Initial creation - unit tests for comment_ops and post_ops +# +# CODE STANDARDS: +# - pytest style with fixtures for database setup +# - Each test uses a fresh in-memory SQLite DB +# - Mocks get_db, close_db, get_caller_branch at the source module level +# ============================================= + +""" +Unit Tests for Comment and Post Operations + +Tests the handler functions in comment_ops.py and post_ops.py, +mocking external dependencies (database connections, caller identity) +and verifying return values and side effects. +""" + +import sqlite3 +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +# Eagerly import the target modules so they are in sys.modules before +# unittest.mock.patch tries to resolve the dotted attribute paths. +import commons.apps.handlers.comments.comment_ops as _comment_ops_mod # noqa: F401 +import commons.apps.handlers.posts.post_ops as _post_ops_mod # noqa: F401 + + +# --------------------------------------------------------------------------- +# Schema path +# --------------------------------------------------------------------------- +SCHEMA_PATH = ( + Path(__file__).resolve().parent.parent + / "apps" + / "handlers" + / "database" + / "schema.sql" +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def db_conn(): + """ + Create a fresh in-memory SQLite database with the full commons schema + and seed data needed for tests. Yields the connection, then closes it. + """ + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + + schema_sql = SCHEMA_PATH.read_text(encoding="utf-8") + # FTS5 virtual tables can cause issues in memory; strip them for unit tests + lines = schema_sql.split("\n") + filtered: list[str] = [] + skip = False + for line in lines: + upper = line.strip().upper() + if upper.startswith("CREATE VIRTUAL TABLE"): + skip = True + continue + if skip: + if ";" in line: + skip = False + continue + filtered.append(line) + conn.executescript("\n".join(filtered)) + + # Seed the SYSTEM agent (room creator) and default rooms + conn.execute( + "INSERT OR IGNORE INTO agents (branch_name, display_name, description) " + "VALUES (?, ?, ?)", + ("SYSTEM", "System", "The Commons system account"), + ) + conn.execute( + "INSERT OR IGNORE INTO rooms (name, display_name, description, created_by) " + "VALUES (?, ?, ?, ?)", + ("general", "General", "Main gathering space", "SYSTEM"), + ) + conn.execute( + "INSERT OR IGNORE INTO rooms (name, display_name, description, created_by) " + "VALUES (?, ?, ?, ?)", + ("dev", "Dev", "Development discussions", "SYSTEM"), + ) + + # Two test agents + conn.execute( + "INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", + ("test-branch", "Test Branch"), + ) + conn.execute( + "INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", + ("other-branch", "Other Branch"), + ) + conn.commit() + + yield conn + conn.close() + + +@pytest.fixture() +def _mock_caller_test_branch(): + """Patch get_caller_branch in BOTH handler modules to return test-branch.""" + caller = {"name": "test-branch", "path": "/mock/test-branch"} + with ( + patch( + "commons.apps.handlers.comments.comment_ops.get_caller_branch", + return_value=caller, + ), + patch( + "commons.apps.handlers.posts.post_ops.get_caller_branch", + return_value=caller, + ), + ): + yield + + +@pytest.fixture() +def _mock_caller_other_branch(): + """Patch get_caller_branch in BOTH handler modules to return other-branch.""" + caller = {"name": "other-branch", "path": "/mock/other-branch"} + with ( + patch( + "commons.apps.handlers.comments.comment_ops.get_caller_branch", + return_value=caller, + ), + patch( + "commons.apps.handlers.posts.post_ops.get_caller_branch", + return_value=caller, + ), + ): + yield + + +@pytest.fixture() +def mock_db(db_conn): + """ + Patch get_db and close_db in both comment_ops and post_ops modules + so they use the in-memory test connection. + """ + with ( + patch( + "commons.apps.handlers.comments.comment_ops.get_db", + return_value=db_conn, + ), + patch( + "commons.apps.handlers.comments.comment_ops.close_db", + ), + patch( + "commons.apps.handlers.posts.post_ops.get_db", + return_value=db_conn, + ), + patch( + "commons.apps.handlers.posts.post_ops.close_db", + ), + # Suppress FTS sync and profile count increments (tested elsewhere) + patch( + "commons.apps.handlers.comments.comment_ops.sync_comment_to_fts", + ), + patch( + "commons.apps.handlers.comments.comment_ops.increment_comment_count", + ), + patch( + "commons.apps.handlers.posts.post_ops.sync_post_to_fts", + ), + patch( + "commons.apps.handlers.posts.post_ops.increment_post_count", + ), + ): + yield db_conn + + +def _insert_post(conn: sqlite3.Connection, *, author: str = "test-branch", + room: str = "general", title: str = "Seed Post", + content: str = "Seed content") -> int: + """Helper: insert a post directly and return its id.""" + cursor = conn.execute( + "INSERT INTO posts (room_name, author, title, content, post_type) " + "VALUES (?, ?, ?, ?, ?)", + (room, author, title, content, "discussion"), + ) + conn.commit() + assert cursor.lastrowid is not None + return cursor.lastrowid + + +def _insert_comment(conn: sqlite3.Connection, post_id: int, *, + author: str = "other-branch", + content: str = "A comment", + parent_id: int | None = None) -> int: + """Helper: insert a comment directly and return its id.""" + cursor = conn.execute( + "INSERT INTO comments (post_id, parent_id, author, content) " + "VALUES (?, ?, ?, ?)", + (post_id, parent_id, author, content), + ) + conn.commit() + assert cursor.lastrowid is not None + return cursor.lastrowid + + +# =========================================================================== +# COMMENT OPS TESTS +# =========================================================================== + +class TestAddComment: + """Tests for comment_ops.add_comment().""" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_success(self, mock_db): + """add_comment with valid args returns success dict with comment_id.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + post_id = _insert_post(mock_db) + result = add_comment([str(post_id), "Hello world"]) + + assert result["success"] is True + assert isinstance(result["comment_id"], int) + assert result["post_id"] == post_id + assert result["author"] == "test-branch" + assert result["parent_id"] is None + assert result["post_title"] == "Seed Post" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_missing_args(self, mock_db): + """add_comment with fewer than 2 positional args returns error.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + result = add_comment(["1"]) + assert result["success"] is False + assert "Usage" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_no_args(self, mock_db): + """add_comment with empty args returns error.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + result = add_comment([]) + assert result["success"] is False + assert "Usage" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_nonexistent_post(self, mock_db): + """add_comment on a nonexistent post returns error.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + result = add_comment(["9999", "No such post"]) + assert result["success"] is False + assert "not found" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_invalid_post_id(self, mock_db): + """add_comment with non-integer post_id returns error.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + result = add_comment(["abc", "content"]) + assert result["success"] is False + assert "Invalid post_id" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_duplicate_detection(self, mock_db): + """add_comment rejects identical content from same author within 5 min.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + post_id = _insert_post(mock_db) + first = add_comment([str(post_id), "Duplicate text"]) + assert first["success"] is True + + second = add_comment([str(post_id), "Duplicate text"]) + assert second["success"] is False + assert "Duplicate" in second["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_with_parent(self, mock_db): + """add_comment with --parent flag creates a nested reply.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + post_id = _insert_post(mock_db) + parent_result = add_comment([str(post_id), "Parent comment"]) + assert parent_result["success"] is True + parent_id = parent_result["comment_id"] + + child_result = add_comment([ + str(post_id), "Reply", "--parent", str(parent_id), + ]) + assert child_result["success"] is True + assert child_result["parent_id"] == parent_id + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_invalid_parent(self, mock_db): + """add_comment with --parent pointing to nonexistent comment returns error.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + post_id = _insert_post(mock_db) + result = add_comment([ + str(post_id), "Reply", "--parent", "9999", + ]) + assert result["success"] is False + assert "Parent comment" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_invalid_parent_value(self, mock_db): + """add_comment with non-integer --parent value returns error.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + result = add_comment(["1", "Reply", "--parent", "xyz"]) + assert result["success"] is False + assert "Invalid --parent" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_add_comment_updates_comment_count(self, mock_db): + """add_comment increments the post comment_count.""" + from commons.apps.handlers.comments.comment_ops import add_comment + + post_id = _insert_post(mock_db) + + row_before = mock_db.execute( + "SELECT comment_count FROM posts WHERE id = ?", (post_id,) + ).fetchone() + assert row_before["comment_count"] == 0 + + add_comment([str(post_id), "Bump the count"]) + + row_after = mock_db.execute( + "SELECT comment_count FROM posts WHERE id = ?", (post_id,) + ).fetchone() + assert row_after["comment_count"] == 1 + + +class TestVoteOnContent: + """Tests for comment_ops.vote_on_content().""" + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_upvote_post(self, mock_db): + """Upvoting a post returns success with new_score=1.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + post_id = _insert_post(mock_db, author="test-branch") + result = vote_on_content(["post", str(post_id), "up"]) + + assert result["success"] is True + assert result["action"] == "voted" + assert result["direction"] == "up" + assert result["target_type"] == "post" + assert result["target_id"] == post_id + assert result["new_score"] == 1 + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_downvote_post(self, mock_db): + """Downvoting a post returns success with new_score=-1.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + post_id = _insert_post(mock_db, author="test-branch") + result = vote_on_content(["post", str(post_id), "down"]) + + assert result["success"] is True + assert result["action"] == "voted" + assert result["direction"] == "down" + assert result["new_score"] == -1 + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_upvote_comment(self, mock_db): + """Upvoting a comment returns success with new_score=1.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + post_id = _insert_post(mock_db, author="test-branch") + comment_id = _insert_comment(mock_db, post_id, author="test-branch") + result = vote_on_content(["comment", str(comment_id), "up"]) + + assert result["success"] is True + assert result["target_type"] == "comment" + assert result["new_score"] == 1 + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_self_vote_prevented(self, mock_db): + """Voting on your own content returns error.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + post_id = _insert_post(mock_db, author="test-branch") + result = vote_on_content(["post", str(post_id), "up"]) + + assert result["success"] is False + assert "Cannot vote on your own content" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_vote_toggle_off(self, mock_db): + """Voting same direction twice toggles the vote off.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + post_id = _insert_post(mock_db, author="test-branch") + vote_on_content(["post", str(post_id), "up"]) + result = vote_on_content(["post", str(post_id), "up"]) + + assert result["success"] is True + assert result["action"] == "removed" + assert result["new_score"] == 0 + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_vote_change_direction(self, mock_db): + """Changing vote direction adjusts score by 2.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + post_id = _insert_post(mock_db, author="test-branch") + vote_on_content(["post", str(post_id), "up"]) + result = vote_on_content(["post", str(post_id), "down"]) + + assert result["success"] is True + assert result["action"] == "changed" + assert result["new_score"] == -1 # was +1, changed by -2 + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_vote_nonexistent_target(self, mock_db): + """Voting on a nonexistent target returns error.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + result = vote_on_content(["post", "9999", "up"]) + assert result["success"] is False + assert "not found" in result["error"] + + def test_vote_missing_args(self, mock_db): + """vote_on_content with fewer than 3 args returns error.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + result = vote_on_content(["post", "1"]) + assert result["success"] is False + assert "Usage" in result["error"] + + def test_vote_invalid_target_type(self, mock_db): + """vote_on_content with bad target_type returns error.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + result = vote_on_content(["thread", "1", "up"]) + assert result["success"] is False + assert "Invalid target type" in result["error"] + + def test_vote_invalid_direction(self, mock_db): + """vote_on_content with bad direction returns error.""" + from commons.apps.handlers.comments.comment_ops import vote_on_content + + result = vote_on_content(["post", "1", "sideways"]) + assert result["success"] is False + assert "Invalid direction" in result["error"] + + +# =========================================================================== +# POST OPS TESTS +# =========================================================================== + +class TestCreatePost: + """Tests for post_ops.create_post().""" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_success(self, mock_db): + """create_post with valid args returns success dict with post_id.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post(["general", "My Title", "Body text"]) + + assert result["success"] is True + assert isinstance(result["post_id"], int) + assert result["title"] == "My Title" + assert result["room"] == "general" + assert result["author"] == "test-branch" + assert result["post_type"] == "discussion" + assert isinstance(result["mentions"], list) + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_missing_args(self, mock_db): + """create_post with fewer than 3 positional args returns error.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post(["general", "Title only"]) + assert result["success"] is False + assert "Usage" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_no_args(self, mock_db): + """create_post with empty args returns error.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post([]) + assert result["success"] is False + assert "Usage" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_nonexistent_room(self, mock_db): + """create_post in a room that does not exist returns error.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post(["nonexistent-room", "Title", "Content"]) + assert result["success"] is False + assert "not found" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_with_type_flag(self, mock_db): + """create_post with --type flag sets the post_type.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post([ + "general", "Question Title", "Question body", + "--type", "question", + ]) + + assert result["success"] is True + assert result["post_type"] == "question" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_invalid_type(self, mock_db): + """create_post with invalid --type value returns error.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post([ + "general", "Title", "Content", "--type", "rant", + ]) + assert result["success"] is False + assert "Invalid post type" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_room_name_lowered(self, mock_db): + """create_post lowercases the room name.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post(["GENERAL", "Title", "Content"]) + assert result["success"] is True + assert result["room"] == "general" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_create_post_stored_in_db(self, mock_db): + """create_post actually inserts the row into the posts table.""" + from commons.apps.handlers.posts.post_ops import create_post + + result = create_post(["general", "DB Check", "Verify insert"]) + assert result["success"] is True + + row = mock_db.execute( + "SELECT * FROM posts WHERE id = ?", (result["post_id"],) + ).fetchone() + assert row is not None + assert row["title"] == "DB Check" + assert row["content"] == "Verify insert" + assert row["author"] == "test-branch" + assert row["room_name"] == "general" + + +class TestViewThread: + """Tests for post_ops.view_thread().""" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_view_thread_success(self, mock_db): + """view_thread returns post dict and list of comment dicts.""" + from commons.apps.handlers.posts.post_ops import view_thread + + post_id = _insert_post(mock_db) + _insert_comment(mock_db, post_id, content="Comment A") + _insert_comment(mock_db, post_id, content="Comment B") + + result = view_thread([str(post_id)]) + + assert result["success"] is True + assert result["post"]["id"] == post_id + assert result["post"]["title"] == "Seed Post" + assert len(result["comments"]) == 2 + assert result["comments"][0]["content"] == "Comment A" + assert result["comments"][1]["content"] == "Comment B" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_view_thread_no_comments(self, mock_db): + """view_thread on a post with no comments returns empty list.""" + from commons.apps.handlers.posts.post_ops import view_thread + + post_id = _insert_post(mock_db) + result = view_thread([str(post_id)]) + + assert result["success"] is True + assert result["comments"] == [] + + def test_view_thread_nonexistent(self, mock_db): + """view_thread on nonexistent post returns error.""" + from commons.apps.handlers.posts.post_ops import view_thread + + result = view_thread(["9999"]) + assert result["success"] is False + assert "not found" in result["error"] + + def test_view_thread_no_args(self, mock_db): + """view_thread with no args returns error.""" + from commons.apps.handlers.posts.post_ops import view_thread + + result = view_thread([]) + assert result["success"] is False + assert "Usage" in result["error"] + + def test_view_thread_invalid_id(self, mock_db): + """view_thread with non-integer id returns error.""" + from commons.apps.handlers.posts.post_ops import view_thread + + result = view_thread(["abc"]) + assert result["success"] is False + assert "Invalid post_id" in result["error"] + + +class TestDeletePost: + """Tests for post_ops.delete_post().""" + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_delete_own_post(self, mock_db): + """delete_post on your own post succeeds and removes the row.""" + from commons.apps.handlers.posts.post_ops import delete_post + + post_id = _insert_post(mock_db, author="test-branch") + result = delete_post([str(post_id)]) + + assert result["success"] is True + assert result["post_id"] == post_id + assert result["author"] == "test-branch" + assert result["title"] == "Seed Post" + + row = mock_db.execute( + "SELECT id FROM posts WHERE id = ?", (post_id,) + ).fetchone() + assert row is None + + @pytest.mark.usefixtures("_mock_caller_other_branch") + def test_delete_other_post_fails(self, mock_db): + """delete_post on someone else's post returns permission error.""" + from commons.apps.handlers.posts.post_ops import delete_post + + post_id = _insert_post(mock_db, author="test-branch") + result = delete_post([str(post_id)]) + + assert result["success"] is False + assert "Permission denied" in result["error"] + assert "test-branch" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_delete_nonexistent_post(self, mock_db): + """delete_post on nonexistent post returns error.""" + from commons.apps.handlers.posts.post_ops import delete_post + + result = delete_post(["9999"]) + assert result["success"] is False + assert "not found" in result["error"] + + def test_delete_no_args(self, mock_db): + """delete_post with no args returns error.""" + from commons.apps.handlers.posts.post_ops import delete_post + + result = delete_post([]) + assert result["success"] is False + assert "Usage" in result["error"] + + def test_delete_invalid_id(self, mock_db): + """delete_post with non-integer id returns error.""" + from commons.apps.handlers.posts.post_ops import delete_post + + result = delete_post(["xyz"]) + assert result["success"] is False + assert "Invalid post_id" in result["error"] + + @pytest.mark.usefixtures("_mock_caller_test_branch") + def test_delete_cascades_comments_and_votes(self, mock_db): + """delete_post cascade-deletes comments and votes on the post.""" + from commons.apps.handlers.posts.post_ops import delete_post + + post_id = _insert_post(mock_db, author="test-branch") + comment_id = _insert_comment(mock_db, post_id, author="other-branch") + + # Add a vote on the post + mock_db.execute( + "INSERT INTO votes (agent_name, target_id, target_type, direction) " + "VALUES (?, ?, ?, ?)", + ("other-branch", post_id, "post", 1), + ) + # Add a vote on the comment + mock_db.execute( + "INSERT INTO votes (agent_name, target_id, target_type, direction) " + "VALUES (?, ?, ?, ?)", + ("test-branch", comment_id, "comment", 1), + ) + mock_db.commit() + + result = delete_post([str(post_id)]) + assert result["success"] is True + + # Verify cascade: comments gone + comments = mock_db.execute( + "SELECT id FROM comments WHERE post_id = ?", (post_id,) + ).fetchall() + assert len(comments) == 0 + + # Verify cascade: votes on post gone + post_votes = mock_db.execute( + "SELECT id FROM votes WHERE target_type = 'post' AND target_id = ?", + (post_id,), + ).fetchall() + assert len(post_votes) == 0 + + # Verify cascade: votes on comment gone + comment_votes = mock_db.execute( + "SELECT id FROM votes WHERE target_type = 'comment' AND target_id = ?", + (comment_id,), + ).fetchall() + assert len(comment_votes) == 0 diff --git a/src/commons/tests/test_curation_explore_welcome_ops.py b/src/commons/tests/test_curation_explore_welcome_ops.py new file mode 100644 index 00000000..74dbe211 --- /dev/null +++ b/src/commons/tests/test_curation_explore_welcome_ops.py @@ -0,0 +1,1139 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_curation_explore_welcome_ops.py +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: commons/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-04-03): Initial creation — ops-layer tests for curation, explore, welcome +# +# CODE STANDARDS: +# - Pytest function style (no unittest classes) +# - Uses initialized_db fixture from conftest.py for DB isolation +# - Mocks get_db, close_db, get_caller_branch, json_handler targeting SOURCE modules +# ============================================= + +""" +Unit tests for the *ops* layer of curation, explore, and welcome handlers. + +These tests exercise the public functions that parse CLI args, acquire a DB +connection, call into the query layer, and return result dicts. The existing +test_curation.py, test_explore_leaderboard.py, and test_welcome_engagement.py +cover the lower-level query functions and module routing; this file focuses on +the ops orchestration that sits above them. + +Covered modules: +- commons.apps.handlers.curation.curation_ops + add_react, remove_react, show_reactions, pin_post_cmd, unpin_post_cmd, + show_pinned, show_trending +- commons.apps.handlers.rooms.explore_ops + explore_rooms, list_secrets +- commons.apps.handlers.welcome.welcome_ops + run_welcome (--dry-run and normal), _welcome_scan, _welcome_specific +""" + +import sqlite3 +from unittest.mock import patch, MagicMock + +import pytest + + +# ============================================================================= +# HELPERS +# ============================================================================= + + +def _seed_agent(conn: sqlite3.Connection, name: str, display: str = "Test") -> None: + """Insert a single agent.""" + conn.execute( + "INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", + (name, display), + ) + conn.commit() + + +def _seed_post( + conn: sqlite3.Connection, + title: str, + room: str, + author: str, + *, + pinned: int = 0, +) -> int: + """Insert a post and return its ID.""" + conn.execute( + "INSERT INTO posts (title, content, room_name, author, pinned) " + "VALUES (?, ?, ?, ?, ?)", + (title, "body", room, author, pinned), + ) + conn.commit() + row = conn.execute("SELECT last_insert_rowid()").fetchone() + return row[0] + + +def _seed_comment(conn: sqlite3.Connection, post_id: int, author: str) -> int: + """Insert a comment and return its ID.""" + conn.execute( + "INSERT INTO comments (post_id, author, content) VALUES (?, ?, ?)", + (post_id, author, "A comment"), + ) + conn.commit() + row = conn.execute("SELECT last_insert_rowid()").fetchone() + return row[0] + + +def _seed_room( + conn: sqlite3.Connection, + name: str, + display_name: str, + created_by: str, + *, + hidden: int = 0, + discovery_hint: str = "", +) -> None: + """Insert a room.""" + conn.execute( + "INSERT OR IGNORE INTO rooms (name, display_name, description, created_by, hidden, discovery_hint) " + "VALUES (?, ?, ?, ?, ?, ?)", + (name, display_name, "desc", created_by, hidden, discovery_hint), + ) + conn.commit() + + +# ============================================================================= +# curation_ops -- add_react +# ============================================================================= + + +@patch("commons.apps.handlers.curation.curation_ops.json_handler") +@patch("commons.apps.handlers.curation.reaction_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_add_react_success_post( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_rq_json: MagicMock, + mock_ops_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """add_react with valid post target returns success with reaction info.""" + from commons.apps.handlers.curation.curation_ops import add_react + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Hello", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = add_react(["post", str(post_id), "thumbsup"]) + + assert result["success"] is True + assert result["is_new"] is True + assert result["reaction"] == "thumbsup" + assert result["target_type"] == "post" + assert result["target_id"] == post_id + assert result["agent"] == "TEST_BRANCH" + + +@patch("commons.apps.handlers.curation.curation_ops.json_handler") +@patch("commons.apps.handlers.curation.reaction_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_add_react_success_comment( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_rq_json: MagicMock, + mock_ops_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """add_react targeting a comment returns success.""" + from commons.apps.handlers.curation.curation_ops import add_react + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Hello", "general", "TEST_BRANCH") + comment_id = _seed_comment(initialized_db, post_id, "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = add_react(["comment", str(comment_id), "agree"]) + + assert result["success"] is True + assert result["target_type"] == "comment" + assert result["target_id"] == comment_id + + +def test_add_react_too_few_args() -> None: + """add_react with fewer than 3 args returns usage error.""" + from commons.apps.handlers.curation.curation_ops import add_react + + result = add_react(["post", "1"]) + assert result["success"] is False + assert "Usage" in result["error"] + + +def test_add_react_invalid_target_type() -> None: + """add_react with invalid target type returns error.""" + from commons.apps.handlers.curation.curation_ops import add_react + + result = add_react(["thread", "1", "thumbsup"]) + assert result["success"] is False + assert "post" in result["error"] or "comment" in result["error"] + + +def test_add_react_non_numeric_id() -> None: + """add_react with non-numeric ID returns error.""" + from commons.apps.handlers.curation.curation_ops import add_react + + result = add_react(["post", "abc", "thumbsup"]) + assert result["success"] is False + assert "number" in result["error"] + + +def test_add_react_invalid_reaction() -> None: + """add_react with invalid reaction name returns error.""" + from commons.apps.handlers.curation.curation_ops import add_react + + result = add_react(["post", "1", "love"]) + assert result["success"] is False + assert "Invalid reaction" in result["error"] + + +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value=None, +) +def test_add_react_no_caller(mock_caller: MagicMock) -> None: + """add_react when caller cannot be detected returns error.""" + from commons.apps.handlers.curation.curation_ops import add_react + + result = add_react(["post", "1", "thumbsup"]) + assert result["success"] is False + assert "calling branch" in result["error"] + + +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_add_react_target_not_found( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """add_react for a non-existent post returns not-found error.""" + from commons.apps.handlers.curation.curation_ops import add_react + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = add_react(["post", "9999", "thumbsup"]) + assert result["success"] is False + assert "not found" in result["error"] + + +# ============================================================================= +# curation_ops -- remove_react +# ============================================================================= + + +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_remove_react_no_existing_reaction( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """remove_react when no reaction exists returns removed=False.""" + from commons.apps.handlers.curation.curation_ops import remove_react + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Hello", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = remove_react(["post", str(post_id), "thumbsup"]) + assert result["success"] is True + assert result["removed"] is False + + +def test_remove_react_too_few_args() -> None: + """remove_react with fewer than 3 args returns error.""" + from commons.apps.handlers.curation.curation_ops import remove_react + + result = remove_react(["post"]) + assert result["success"] is False + assert "Usage" in result["error"] + + +def test_remove_react_invalid_target_type() -> None: + """remove_react with invalid target type returns error.""" + from commons.apps.handlers.curation.curation_ops import remove_react + + result = remove_react(["thread", "1", "thumbsup"]) + assert result["success"] is False + + +def test_remove_react_non_numeric_id() -> None: + """remove_react with non-numeric ID returns error.""" + from commons.apps.handlers.curation.curation_ops import remove_react + + result = remove_react(["post", "xyz", "thumbsup"]) + assert result["success"] is False + assert "number" in result["error"] + + +def test_remove_react_invalid_reaction() -> None: + """remove_react with invalid reaction returns error.""" + from commons.apps.handlers.curation.curation_ops import remove_react + + result = remove_react(["post", "1", "love"]) + assert result["success"] is False + + +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value=None, +) +def test_remove_react_no_caller(mock_caller: MagicMock) -> None: + """remove_react when caller cannot be detected returns error.""" + from commons.apps.handlers.curation.curation_ops import remove_react + + result = remove_react(["post", "1", "thumbsup"]) + assert result["success"] is False + assert "calling branch" in result["error"] + + +# ============================================================================= +# curation_ops -- show_reactions +# ============================================================================= + + +@patch("commons.apps.handlers.curation.reaction_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +def test_show_reactions_empty( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_rq_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """show_reactions on a post with no reactions returns empty dict.""" + from commons.apps.handlers.curation.curation_ops import show_reactions + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Hello", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = show_reactions(["post", str(post_id)]) + assert result["success"] is True + assert result["reactions"] == {} + assert result["target_type"] == "post" + assert result["target_id"] == post_id + + +def test_show_reactions_too_few_args() -> None: + """show_reactions with fewer than 2 args returns error.""" + from commons.apps.handlers.curation.curation_ops import show_reactions + + result = show_reactions(["post"]) + assert result["success"] is False + assert "Usage" in result["error"] + + +def test_show_reactions_invalid_target() -> None: + """show_reactions with invalid target type returns error.""" + from commons.apps.handlers.curation.curation_ops import show_reactions + + result = show_reactions(["thread", "1"]) + assert result["success"] is False + + +def test_show_reactions_non_numeric_id() -> None: + """show_reactions with non-numeric ID returns error.""" + from commons.apps.handlers.curation.curation_ops import show_reactions + + result = show_reactions(["post", "abc"]) + assert result["success"] is False + assert "number" in result["error"] + + +# ============================================================================= +# curation_ops -- pin_post_cmd +# ============================================================================= + + +@patch("commons.apps.handlers.curation.curation_ops.json_handler") +@patch("commons.apps.handlers.curation.pin_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_pin_post_cmd_success_by_author( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_pin_json: MagicMock, + mock_ops_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """pin_post_cmd by the post author succeeds.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Pin Me", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = pin_post_cmd([str(post_id)]) + assert result["success"] is True + assert result["action"] == "pinned" + assert result["post_id"] == post_id + assert result["title"] == "Pin Me" + assert result["agent"] == "TEST_BRANCH" + + +@patch("commons.apps.handlers.curation.curation_ops.json_handler") +@patch("commons.apps.handlers.curation.pin_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "SYSTEM"}, +) +def test_pin_post_cmd_success_by_system( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_pin_json: MagicMock, + mock_ops_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """SYSTEM can pin any post regardless of authorship.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Pin Me", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = pin_post_cmd([str(post_id)]) + assert result["success"] is True + assert result["agent"] == "SYSTEM" + + +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "OTHER_BRANCH"}, +) +def test_pin_post_cmd_rejected_non_author( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """Non-author, non-SYSTEM caller cannot pin a post.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + _seed_agent(initialized_db, "TEST_BRANCH") + _seed_agent(initialized_db, "OTHER_BRANCH") + post_id = _seed_post(initialized_db, "No Pin", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = pin_post_cmd([str(post_id)]) + assert result["success"] is False + assert "author" in result["error"] or "SYSTEM" in result["error"] + + +@patch("commons.apps.handlers.curation.pin_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_pin_post_cmd_already_pinned( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_pin_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """Pinning an already-pinned post returns error.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Already Pinned", "general", "TEST_BRANCH", pinned=1) + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = pin_post_cmd([str(post_id)]) + assert result["success"] is False + assert "already pinned" in result["error"] + + +def test_pin_post_cmd_no_args() -> None: + """pin_post_cmd with no args returns error.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + result = pin_post_cmd([]) + assert result["success"] is False + assert "Usage" in result["error"] + + +def test_pin_post_cmd_non_numeric() -> None: + """pin_post_cmd with non-numeric ID returns error.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + result = pin_post_cmd(["abc"]) + assert result["success"] is False + assert "number" in result["error"] + + +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value=None, +) +def test_pin_post_cmd_no_caller(mock_caller: MagicMock) -> None: + """pin_post_cmd when caller cannot be detected returns error.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + result = pin_post_cmd(["1"]) + assert result["success"] is False + assert "calling branch" in result["error"] + + +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_pin_post_cmd_post_not_found( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """pin_post_cmd for non-existent post returns error.""" + from commons.apps.handlers.curation.curation_ops import pin_post_cmd + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = pin_post_cmd(["9999"]) + assert result["success"] is False + assert "not found" in result["error"] + + +# ============================================================================= +# curation_ops -- unpin_post_cmd +# ============================================================================= + + +@patch("commons.apps.handlers.curation.curation_ops.json_handler") +@patch("commons.apps.handlers.curation.pin_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_unpin_post_cmd_success( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_pin_json: MagicMock, + mock_ops_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """unpin_post_cmd on a pinned post by its author succeeds.""" + from commons.apps.handlers.curation.curation_ops import unpin_post_cmd + + _seed_agent(initialized_db, "TEST_BRANCH") + post_id = _seed_post(initialized_db, "Unpin Me", "general", "TEST_BRANCH", pinned=1) + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = unpin_post_cmd([str(post_id)]) + assert result["success"] is True + assert result["action"] == "unpinned" + assert result["post_id"] == post_id + assert result["title"] == "Unpin Me" + + +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +@patch( + "commons.apps.handlers.curation.curation_ops.get_caller_branch", + return_value={"name": "OTHER_BRANCH"}, +) +def test_unpin_post_cmd_rejected_non_author( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """Non-author, non-SYSTEM caller cannot unpin a post.""" + from commons.apps.handlers.curation.curation_ops import unpin_post_cmd + + _seed_agent(initialized_db, "TEST_BRANCH") + _seed_agent(initialized_db, "OTHER_BRANCH") + post_id = _seed_post(initialized_db, "Pinned", "general", "TEST_BRANCH", pinned=1) + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = unpin_post_cmd([str(post_id)]) + assert result["success"] is False + + +def test_unpin_post_cmd_no_args() -> None: + """unpin_post_cmd with no args returns error.""" + from commons.apps.handlers.curation.curation_ops import unpin_post_cmd + + result = unpin_post_cmd([]) + assert result["success"] is False + assert "Usage" in result["error"] + + +# ============================================================================= +# curation_ops -- show_pinned +# ============================================================================= + + +@patch("commons.apps.handlers.curation.pin_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +def test_show_pinned_no_pinned( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_pin_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """show_pinned with no pinned posts returns empty list.""" + from commons.apps.handlers.curation.curation_ops import show_pinned + + _seed_agent(initialized_db, "TEST_BRANCH") + _seed_post(initialized_db, "Not Pinned", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = show_pinned([]) + assert result["success"] is True + assert result["posts"] == [] + assert result["room"] is None + + +@patch("commons.apps.handlers.curation.pin_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +def test_show_pinned_with_room_filter( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_pin_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """show_pinned with --room filter returns only pinned posts in that room.""" + from commons.apps.handlers.curation.curation_ops import show_pinned + + _seed_agent(initialized_db, "TEST_BRANCH") + _seed_post(initialized_db, "General Pin", "general", "TEST_BRANCH", pinned=1) + _seed_post(initialized_db, "Dev Pin", "dev", "TEST_BRANCH", pinned=1) + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = show_pinned(["--room", "general"]) + assert result["success"] is True + assert result["room"] == "general" + assert len(result["posts"]) == 1 + assert result["posts"][0]["title"] == "General Pin" + + +# ============================================================================= +# curation_ops -- show_trending +# ============================================================================= + + +@patch("commons.apps.handlers.curation.trending_queries.json_handler") +@patch("commons.apps.handlers.curation.curation_ops.close_db") +@patch("commons.apps.handlers.curation.curation_ops.get_db") +def test_show_trending_empty( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_trending_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """show_trending with no engagement returns empty list.""" + from commons.apps.handlers.curation.curation_ops import show_trending + + _seed_agent(initialized_db, "TEST_BRANCH") + _seed_post(initialized_db, "Quiet Post", "general", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = show_trending([]) + assert result["success"] is True + assert result["posts"] == [] + + +# ============================================================================= +# explore_ops -- explore_rooms +# ============================================================================= + + +@patch("commons.apps.handlers.rooms.explore_ops.json_handler") +@patch("commons.apps.handlers.rooms.explore_ops.close_db") +@patch("commons.apps.handlers.rooms.explore_ops.get_db") +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_explore_rooms_no_hidden_rooms( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """explore_rooms with no hidden rooms returns empty list.""" + from commons.apps.handlers.rooms.explore_ops import explore_rooms + + _seed_agent(initialized_db, "TEST_BRANCH") + # Remove any hidden rooms that may have been seeded by init_db + initialized_db.execute("UPDATE rooms SET hidden = 0") + initialized_db.commit() + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = explore_rooms([]) + assert result["success"] is True + assert result["hidden_rooms"] == [] + assert result["rooms_visited"] == 0 + + +@patch("commons.apps.handlers.rooms.explore_ops.json_handler") +@patch("commons.apps.handlers.rooms.explore_ops.close_db") +@patch("commons.apps.handlers.rooms.explore_ops.get_db") +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_explore_rooms_with_hidden_rooms_no_reveal( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """explore_rooms with hidden rooms but < 3 rooms visited does not reveal.""" + from commons.apps.handlers.rooms.explore_ops import explore_rooms + + _seed_agent(initialized_db, "TEST_BRANCH") + # Ensure no pre-existing hidden rooms interfere + initialized_db.execute("UPDATE rooms SET hidden = 0") + initialized_db.commit() + _seed_room(initialized_db, "secret-lab", "Secret Lab", "SYSTEM", hidden=1, discovery_hint="Look deeper") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = explore_rooms([]) + assert result["success"] is True + assert len(result["hidden_rooms"]) == 1 + assert result["hidden_rooms"][0]["name"] == "secret-lab" + assert "revealed" not in result + + +@patch("commons.apps.handlers.rooms.explore_ops.json_handler") +@patch("commons.apps.handlers.rooms.explore_ops.close_db") +@patch("commons.apps.handlers.rooms.explore_ops.get_db") +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_explore_rooms_reveals_after_3_rooms( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """explore_rooms reveals a hidden room when the caller has visited 3+ rooms.""" + from commons.apps.handlers.rooms.explore_ops import explore_rooms + + _seed_agent(initialized_db, "TEST_BRANCH") + # Clear any pre-existing hidden rooms + initialized_db.execute("UPDATE rooms SET hidden = 0") + initialized_db.commit() + + # Create 3 regular rooms and post in each + for room in ("room-a", "room-b", "room-c"): + _seed_room(initialized_db, room, room.title(), "SYSTEM") + _seed_post(initialized_db, f"Post in {room}", room, "TEST_BRANCH") + + # Create the hidden room to be discovered + _seed_room(initialized_db, "vault", "The Vault", "SYSTEM", hidden=1, discovery_hint="Find the key") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = explore_rooms([]) + assert result["success"] is True + assert result["rooms_visited"] >= 3 + assert "revealed" in result + assert result["revealed"]["name"] == "vault" + + +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value=None, +) +def test_explore_rooms_no_caller(mock_caller: MagicMock) -> None: + """explore_rooms when caller cannot be detected returns error.""" + from commons.apps.handlers.rooms.explore_ops import explore_rooms + + result = explore_rooms([]) + assert result["success"] is False + assert "calling branch" in result["error"] + + +# ============================================================================= +# explore_ops -- list_secrets +# ============================================================================= + + +@patch("commons.apps.handlers.rooms.explore_ops.close_db") +@patch("commons.apps.handlers.rooms.explore_ops.get_db") +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_list_secrets_none_discovered( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """list_secrets when no hidden rooms have been posted in returns empty.""" + from commons.apps.handlers.rooms.explore_ops import list_secrets + + _seed_agent(initialized_db, "TEST_BRANCH") + # Clear any pre-existing hidden rooms + initialized_db.execute("UPDATE rooms SET hidden = 0") + initialized_db.commit() + _seed_room(initialized_db, "hidden-cove", "Hidden Cove", "SYSTEM", hidden=1) + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = list_secrets([]) + assert result["success"] is True + assert result["discovered"] == [] + assert result["total_hidden"] == 1 + + +@patch("commons.apps.handlers.rooms.explore_ops.close_db") +@patch("commons.apps.handlers.rooms.explore_ops.get_db") +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_list_secrets_with_discovered_room( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """list_secrets returns rooms where the caller has posted.""" + from commons.apps.handlers.rooms.explore_ops import list_secrets + + _seed_agent(initialized_db, "TEST_BRANCH") + # Clear any pre-existing hidden rooms + initialized_db.execute("UPDATE rooms SET hidden = 0") + initialized_db.commit() + _seed_room(initialized_db, "hidden-cove", "Hidden Cove", "SYSTEM", hidden=1) + _seed_post(initialized_db, "Secret Post", "hidden-cove", "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = list_secrets([]) + assert result["success"] is True + assert len(result["discovered"]) == 1 + assert result["discovered"][0]["name"] == "hidden-cove" + assert result["total_hidden"] == 1 + + +@patch("commons.apps.handlers.rooms.explore_ops.close_db") +@patch("commons.apps.handlers.rooms.explore_ops.get_db") +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value={"name": "TEST_BRANCH"}, +) +def test_list_secrets_discovered_via_comment( + mock_caller: MagicMock, + mock_get_db: MagicMock, + mock_close: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """list_secrets counts rooms discovered by commenting on a post in that room.""" + from commons.apps.handlers.rooms.explore_ops import list_secrets + + _seed_agent(initialized_db, "TEST_BRANCH") + _seed_agent(initialized_db, "OTHER_BRANCH") + # Clear any pre-existing hidden rooms + initialized_db.execute("UPDATE rooms SET hidden = 0") + initialized_db.commit() + _seed_room(initialized_db, "hidden-den", "Hidden Den", "SYSTEM", hidden=1) + + # Another branch posts in the hidden room; TEST_BRANCH comments + post_id = _seed_post(initialized_db, "Secret Thread", "hidden-den", "OTHER_BRANCH") + _seed_comment(initialized_db, post_id, "TEST_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = list_secrets([]) + assert result["success"] is True + assert len(result["discovered"]) == 1 + assert result["discovered"][0]["name"] == "hidden-den" + + +@patch( + "commons.apps.modules.commons_identity.get_caller_branch", + return_value=None, +) +def test_list_secrets_no_caller(mock_caller: MagicMock) -> None: + """list_secrets when caller cannot be detected returns error.""" + from commons.apps.handlers.rooms.explore_ops import list_secrets + + result = list_secrets([]) + assert result["success"] is False + assert "calling branch" in result["error"] + + +# ============================================================================= +# welcome_ops -- run_welcome (dry-run mode) +# ============================================================================= + + +@patch("commons.apps.handlers.welcome.welcome_handler.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_dry_run_scan( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + mock_handler_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome --dry-run with no specific branch lists unwelcomed branches.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + + _seed_agent(initialized_db, "ALPHA") + _seed_agent(initialized_db, "BETA") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome(["--dry-run"]) + assert result["success"] is True + assert result["dry_run"] is True + assert isinstance(result["would_welcome"], list) + assert "ALPHA" in result["would_welcome"] + assert "BETA" in result["would_welcome"] + + +@patch("commons.apps.handlers.welcome.welcome_handler.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_dry_run_specific_branch( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + mock_handler_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome --dry-run reports whether the branch would be welcomed.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + + _seed_agent(initialized_db, "GAMMA") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome(["gamma", "--dry-run"]) + assert result["success"] is True + assert result["dry_run"] is True + assert result["branch"] == "GAMMA" + assert result["would_welcome"] is True + + +@patch("commons.apps.handlers.welcome.welcome_handler.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_dry_run_already_welcomed( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + mock_handler_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome --dry-run for already-welcomed branch reports would_welcome=False.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + from commons.apps.handlers.welcome.welcome_handler import create_welcome_post + + _seed_agent(initialized_db, "DELTA") + create_welcome_post(initialized_db, "DELTA") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome(["delta", "--dry-run"]) + assert result["success"] is True + assert result["dry_run"] is True + assert result["would_welcome"] is False + + +# ============================================================================= +# welcome_ops -- run_welcome (normal mode) +# ============================================================================= + + +@patch("commons.apps.handlers.welcome.welcome_handler.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_scan_welcomes_new_branches( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + mock_handler_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome with no args scans and welcomes all unwelcomed branches.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + + _seed_agent(initialized_db, "NEW_BRANCH") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome([]) + assert result["success"] is True + assert result["action"] == "scan" + assert "NEW_BRANCH" in result["welcomed"] + + +@patch("commons.apps.handlers.welcome.welcome_handler.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_specific_branch_success( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + mock_handler_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome creates a welcome post for that branch.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + + _seed_agent(initialized_db, "EPSILON") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome(["epsilon"]) + assert result["success"] is True + assert result["action"] == "specific" + assert result["already_welcomed"] is False + assert result["branch"] == "EPSILON" + assert result["post_id"] is not None + + +@patch("commons.apps.handlers.welcome.welcome_handler.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_specific_already_welcomed( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + mock_handler_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome when already welcomed returns already_welcomed=True.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + from commons.apps.handlers.welcome.welcome_handler import create_welcome_post + + _seed_agent(initialized_db, "ZETA") + create_welcome_post(initialized_db, "ZETA") + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome(["zeta"]) + assert result["success"] is True + assert result["action"] == "specific" + assert result["already_welcomed"] is True + + +@patch("commons.apps.handlers.welcome.welcome_ops.json_handler") +@patch("commons.apps.handlers.welcome.welcome_ops.close_db") +@patch("commons.apps.handlers.welcome.welcome_ops.get_db") +def test_run_welcome_specific_branch_not_found( + mock_get_db: MagicMock, + mock_close: MagicMock, + mock_ops_json: MagicMock, + initialized_db: sqlite3.Connection, +) -> None: + """run_welcome returns not-found error.""" + from commons.apps.handlers.welcome.welcome_ops import run_welcome + + mock_get_db.return_value = initialized_db + mock_close.side_effect = lambda c: None + + result = run_welcome(["NONEXISTENT"]) + assert result["success"] is False + assert "not found" in result["error"] diff --git a/src/commons/tests/test_notification_ops.py b/src/commons/tests/test_notification_ops.py new file mode 100644 index 00000000..951ad8b9 --- /dev/null +++ b/src/commons/tests/test_notification_ops.py @@ -0,0 +1,812 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_notification_ops.py - Notification Operations Tests +# Date: 2026-04-03 +# Version: 1.0.0 +# Category: commons/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-04-03): Initial creation — notification_ops handler tests +# +# CODE STANDARDS: +# - Pytest function style (no unittest classes) +# - Uses initialized_db fixture from conftest.py for DB isolation +# - Mocks get_db, close_db, get_caller_branch, json_handler, and logger +# ============================================= + +""" +Unit tests for notification_ops.py — the high-level notification operations layer. + +Covers: +- set_watch: watch a room, post, or thread +- set_mute: mute a room, post, or thread +- set_track: track a room, post, or thread +- _set_notification_level: shared arg parsing, validation, target existence checks +- show_preferences: display all preferences for the calling agent + +NOTE: test_notifications.py already covers the lower-level preferences.py functions +(set_preference, get_preference, get_all_preferences, should_notify, get_watchers). +These tests focus on the operations layer: arg parsing, caller detection, DB lifecycle, +target validation, and error paths. +""" + +import sqlite3 +from unittest.mock import patch, MagicMock + +import pytest + + +# ============================================================================= +# HELPERS +# ============================================================================= + + +def _insert_agent(conn: sqlite3.Connection, name: str = "test-branch") -> None: + """Insert a test agent so foreign key constraints are satisfied.""" + conn.execute( + "INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", + (name, name.replace("-", " ").title()), + ) + conn.commit() + + +def _insert_room(conn: sqlite3.Connection, name: str = "general") -> None: + """Insert a room (requires SYSTEM agent).""" + _insert_agent(conn, "SYSTEM") + conn.execute( + "INSERT OR IGNORE INTO rooms (name, display_name, description, created_by) " + "VALUES (?, ?, ?, ?)", + (name, name.title(), f"Test room {name}", "SYSTEM"), + ) + conn.commit() + + +def _insert_post(conn: sqlite3.Connection, post_id: int = 1, room: str = "general", author: str = "test-branch") -> int: + """Insert a post and return its id.""" + _insert_agent(conn, author) + _insert_room(conn, room) + conn.execute( + "INSERT OR REPLACE INTO posts (id, room_name, author, title, content) " + "VALUES (?, ?, ?, ?, ?)", + (post_id, room, author, "Test Post", "Test content"), + ) + conn.commit() + return post_id + + +# The mock target paths — all point into notification_ops module namespace +_MOCK_GET_DB = "commons.apps.handlers.notifications.notification_ops.get_db" +_MOCK_CLOSE_DB = "commons.apps.handlers.notifications.notification_ops.close_db" +_MOCK_CALLER = "commons.apps.handlers.notifications.notification_ops.get_caller_branch" +_MOCK_JSON = "commons.apps.handlers.notifications.notification_ops.json_handler" +_MOCK_LOGGER = "commons.apps.handlers.notifications.notification_ops.logger" +# Also mock the preferences-layer logger/json to avoid side effects +_MOCK_PREF_JSON = "commons.apps.handlers.notifications.preferences.json_handler" +_MOCK_PREF_LOGGER = "commons.apps.handlers.notifications.preferences.logger" + + +# ============================================================================= +# set_watch +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_room_success( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """set_watch should set notification level to 'watch' for a valid room.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + # 'general' room is seeded by initialized_db + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "general"]) + + assert result["success"] is True + assert result["level"] == "watch" + assert result["target_type"] == "room" + assert result["target_id"] == "general" + assert result["agent"] == "test-branch" + mock_close_db.assert_called_once_with(conn) + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_post_success( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """set_watch should set notification level to 'watch' for a valid post.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + _insert_post(conn, post_id=42) + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["post", "42"]) + + assert result["success"] is True + assert result["level"] == "watch" + assert result["target_type"] == "post" + assert result["target_id"] == "42" + + +# ============================================================================= +# set_mute +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_mute_room_success( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """set_mute should set notification level to 'mute' for a valid room.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute(["room", "general"]) + + assert result["success"] is True + assert result["level"] == "mute" + assert result["target_type"] == "room" + assert result["target_id"] == "general" + assert result["agent"] == "test-branch" + + +# ============================================================================= +# set_track +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_track_thread_success( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """set_track should set notification level to 'track' for a valid thread (post).""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + _insert_post(conn, post_id=10) + + from commons.apps.handlers.notifications.notification_ops import set_track + + result = set_track(["thread", "10"]) + + assert result["success"] is True + assert result["level"] == "track" + assert result["target_type"] == "thread" + assert result["target_id"] == "10" + + +# ============================================================================= +# Argument validation (too few args) +# ============================================================================= + + +def test_set_watch_too_few_args() -> None: + """set_watch with fewer than 2 args should return usage error.""" + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room"]) + assert result["success"] is False + assert "Usage" in result["error"] + + +def test_set_mute_no_args() -> None: + """set_mute with no args should return usage error.""" + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute([]) + assert result["success"] is False + assert "Usage" in result["error"] + + +def test_set_track_single_arg() -> None: + """set_track with 1 arg should return usage error.""" + from commons.apps.handlers.notifications.notification_ops import set_track + + result = set_track(["post"]) + assert result["success"] is False + assert "Usage" in result["error"] + + +# ============================================================================= +# Invalid target type +# ============================================================================= + + +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +def test_set_watch_invalid_target_type(mock_caller: MagicMock) -> None: + """Passing an unsupported target type should return an error.""" + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["channel", "general"]) + assert result["success"] is False + assert "Invalid target type" in result["error"] + assert "'channel'" in result["error"] + + +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +def test_set_mute_invalid_target_type(mock_caller: MagicMock) -> None: + """Passing 'user' as target type should fail validation.""" + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute(["user", "someone"]) + assert result["success"] is False + assert "Invalid target type" in result["error"] + + +# ============================================================================= +# Caller not detected +# ============================================================================= + + +@patch(_MOCK_CALLER, return_value=None) +def test_set_watch_no_caller(mock_caller: MagicMock) -> None: + """When get_caller_branch returns None, operations should fail with caller error.""" + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "general"]) + assert result["success"] is False + assert "Could not detect calling branch" in result["error"] + + +@patch(_MOCK_CALLER, return_value=None) +def test_set_mute_no_caller(mock_caller: MagicMock) -> None: + """set_mute should also fail when caller is undetectable.""" + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute(["room", "general"]) + assert result["success"] is False + assert "Could not detect" in result["error"] + + +@patch(_MOCK_CALLER, return_value=None) +def test_set_track_no_caller(mock_caller: MagicMock) -> None: + """set_track should also fail when caller is undetectable.""" + from commons.apps.handlers.notifications.notification_ops import set_track + + result = set_track(["post", "1"]) + assert result["success"] is False + assert "Could not detect" in result["error"] + + +# ============================================================================= +# Target does not exist in DB +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_room_not_found( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Watching a nonexistent room should return room-not-found error.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "nonexistent-room"]) + assert result["success"] is False + assert "not found" in result["error"] + mock_close_db.assert_called_once_with(conn) + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_mute_post_not_found( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Muting a nonexistent post should return post-not-found error.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute(["post", "9999"]) + assert result["success"] is False + assert "not found" in result["error"] + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_track_thread_not_found( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Tracking a nonexistent thread should return not-found error.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_track + + result = set_track(["thread", "8888"]) + assert result["success"] is False + assert "not found" in result["error"] + + +# ============================================================================= +# Invalid post/thread ID (not a number) +# ============================================================================= + + +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_post_id_not_numeric( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + initialized_db: object, +) -> None: + """Watching a post with a non-numeric ID should return an error.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["post", "abc"]) + assert result["success"] is False + assert "must be a number" in result["error"] + + +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_track_thread_id_not_numeric( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + initialized_db: object, +) -> None: + """Tracking a thread with a non-numeric ID should return an error.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_track + + result = set_track(["thread", "not-a-number"]) + assert result["success"] is False + assert "must be a number" in result["error"] + + +# ============================================================================= +# Room name case normalization +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_room_name_lowercased( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Room names should be lowercased before lookup and storage.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_watch + + # Pass mixed-case — 'General' should resolve to 'general' + result = set_watch(["room", "General"]) + assert result["success"] is True + assert result["target_id"] == "general" + + +# ============================================================================= +# Target type case normalization +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_mute_target_type_case_insensitive( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Target type should be lowercased, so 'ROOM' works like 'room'.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute(["ROOM", "general"]) + assert result["success"] is True + assert result["target_type"] == "room" + + +# ============================================================================= +# DB exception handling +# ============================================================================= + + +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_GET_DB, side_effect=Exception("disk full")) +def test_set_watch_db_exception( + mock_get_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, +) -> None: + """When get_db raises an exception, result should capture the error.""" + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "general"]) + assert result["success"] is False + assert "disk full" in result["error"] + + +# ============================================================================= +# show_preferences +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_show_preferences_empty( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """show_preferences with no preferences set should return empty list.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import show_preferences + + result = show_preferences([]) + assert result["success"] is True + assert result["agent"] == "test-branch" + assert result["preferences"] == [] + mock_close_db.assert_called_once_with(conn) + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_show_preferences_with_data( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """show_preferences should return all preferences for the agent.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.preferences import set_preference + from commons.apps.handlers.notifications.notification_ops import show_preferences + + set_preference(conn, "test-branch", "room", "general", "watch") + set_preference(conn, "test-branch", "post", "5", "mute") + + result = show_preferences([]) + assert result["success"] is True + assert result["agent"] == "test-branch" + assert len(result["preferences"]) == 2 + + levels = {(p["target_type"], p["target_id"]): p["level"] for p in result["preferences"]} + assert levels[("room", "general")] == "watch" + assert levels[("post", "5")] == "mute" + + +@patch(_MOCK_CALLER, return_value=None) +def test_show_preferences_no_caller(mock_caller: MagicMock) -> None: + """show_preferences should fail when caller is not detected.""" + from commons.apps.handlers.notifications.notification_ops import show_preferences + + result = show_preferences([]) + assert result["success"] is False + assert "Could not detect" in result["error"] + + +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_GET_DB, side_effect=Exception("connection refused")) +def test_show_preferences_db_exception( + mock_get_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, +) -> None: + """show_preferences should handle DB exceptions gracefully.""" + from commons.apps.handlers.notifications.notification_ops import show_preferences + + result = show_preferences([]) + assert result["success"] is False + assert "connection refused" in result["error"] + + +# ============================================================================= +# json_handler.log_operation is called on success +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_logs_operation( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Successful watch should call json_handler.log_operation with 'notification_set'.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "general"]) + assert result["success"] is True + mock_json.log_operation.assert_called_once_with( + "notification_set", + {"agent": "test-branch", "level": "watch", "target_type": "room"}, + ) + + +# ============================================================================= +# set_preference returns False path +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +@patch("commons.apps.handlers.notifications.notification_ops.set_preference", return_value=False) +def test_set_watch_preference_fails( + mock_set_pref: MagicMock, + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """When set_preference returns False, the operation should report failure.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "general"]) + assert result["success"] is False + assert "Failed to set preference" in result["error"] + + +# ============================================================================= +# Extra args are ignored (only first two used) +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_watch_extra_args_ignored( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Extra arguments beyond the first two should be ignored.""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + + from commons.apps.handlers.notifications.notification_ops import set_watch + + result = set_watch(["room", "general", "extra", "stuff"]) + assert result["success"] is True + assert result["target_type"] == "room" + assert result["target_id"] == "general" + + +# ============================================================================= +# Post ID normalization (string -> int -> string) +# ============================================================================= + + +@patch(_MOCK_PREF_LOGGER) +@patch(_MOCK_PREF_JSON) +@patch(_MOCK_LOGGER) +@patch(_MOCK_JSON) +@patch(_MOCK_CALLER, return_value={"name": "test-branch"}) +@patch(_MOCK_CLOSE_DB) +@patch(_MOCK_GET_DB) +def test_set_mute_post_id_normalized( + mock_get_db: MagicMock, + mock_close_db: MagicMock, + mock_caller: MagicMock, + mock_json: MagicMock, + mock_logger: MagicMock, + mock_pref_json: MagicMock, + mock_pref_logger: MagicMock, + initialized_db: object, +) -> None: + """Post ID should be normalized through int conversion (e.g. '042' -> '42').""" + conn: sqlite3.Connection = initialized_db # type: ignore[assignment] + mock_get_db.return_value = conn + _insert_agent(conn, "test-branch") + _insert_post(conn, post_id=42) + + from commons.apps.handlers.notifications.notification_ops import set_mute + + result = set_mute(["post", "042"]) + assert result["success"] is True + assert result["target_id"] == "42" diff --git a/src/skills/tests/test_creator.py b/src/skills/tests/test_creator.py new file mode 100644 index 00000000..255c1a82 --- /dev/null +++ b/src/skills/tests/test_creator.py @@ -0,0 +1,164 @@ +# =================== AIPass ==================== +# Name: test_creator.py +# Description: Tests for creator module orchestration layer +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for modules/creator.py — thin orchestration layer. + +Covers: handle_command (routing, introspection, --help), create_skill +(delegation to handler, Rich output, trigger firing, json logging), +print_introspection. +""" + +from unittest.mock import MagicMock, patch + +from skills.apps.modules.creator import create_skill, handle_command, print_introspection + + +# =================================================================== +# 1. handle_command — command routing +# =================================================================== + + +class TestHandleCommand: + """Tests for handle_command — CLI routing logic.""" + + def test_no_args_shows_introspection(self, capsys): + result = handle_command("create", []) + assert result is True + output = capsys.readouterr().out + assert "creator Module" in output + + def test_help_flag_shows_introspection(self, capsys): + result = handle_command("create", ["--help"]) + assert result is True + output = capsys.readouterr().out + assert "creator Module" in output + + def test_create_with_valid_name(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = handle_command("create", ["test-skill"]) + assert result is True + + def test_create_with_handler_flag(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = handle_command("create", ["test-hnd", "--with-handler"]) + assert result is True + skill_path = tmp_path / ".aipass" / "skills" / "test-hnd" + assert (skill_path / "handler.py").exists() + + def test_create_with_full_flag(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = handle_command("create", ["test-full", "--full"]) + assert result is True + skill_path = tmp_path / ".aipass" / "skills" / "test-full" + assert (skill_path / "apps").is_dir() + + def test_create_invalid_name_returns_false(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = handle_command("create", ["Bad Name!"]) + assert result is False + + def test_unknown_command_returns_false(self): + result = handle_command("nonexistent", ["arg"]) + assert result is False + + +# =================================================================== +# 2. create_skill — module-level wrapper +# =================================================================== + + +class TestCreateSkillModule: + """Tests for create_skill module wrapper — delegates + renders output.""" + + def test_success_prints_output(self, tmp_path, capsys): + result = create_skill("print-test", template_type="markdown_only", target_dir=tmp_path) + assert result["success"] is True + output = capsys.readouterr().out + assert "print-test" in output + assert "markdown_only" in output + + def test_success_returns_handler_result(self, tmp_path): + result = create_skill("result-test", template_type="markdown_only", target_dir=tmp_path) + assert result["success"] is True + assert result["path"] is not None + assert isinstance(result["files"], list) + assert result["error"] is None + + def test_failure_does_not_print_success_output(self, capsys): + result = create_skill("", template_type="markdown_only") + assert result["success"] is False + output = capsys.readouterr().out + assert "Created skill" not in output + + def test_trigger_fired_on_success(self, tmp_path): + mock_trigger = MagicMock() + with patch("skills.apps.modules.creator.trigger", mock_trigger): + create_skill("trigger-test", template_type="markdown_only", target_dir=tmp_path) + mock_trigger.fire.assert_called_once() + call_args = mock_trigger.fire.call_args + assert call_args[0][0] == "skill_created" + assert call_args[1]["name"] == "trigger-test" + + def test_trigger_not_fired_on_failure(self): + mock_trigger = MagicMock() + with patch("skills.apps.modules.creator.trigger", mock_trigger): + create_skill("", template_type="markdown_only") + mock_trigger.fire.assert_not_called() + + def test_trigger_none_does_not_crash(self, tmp_path): + """When trigger is None (import failed), create_skill still works.""" + with patch("skills.apps.modules.creator.trigger", None): + result = create_skill("no-trigger", template_type="markdown_only", target_dir=tmp_path) + assert result["success"] is True + + @patch("skills.apps.modules.creator.json_handler") + def test_json_log_on_success(self, mock_jh, tmp_path): + create_skill("jlog-test", template_type="markdown_only", target_dir=tmp_path) + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args + assert call_args[0][0] == "skill_created" + assert call_args[0][1]["success"] is True + + @patch("skills.apps.modules.creator.json_handler") + def test_json_log_on_failure(self, mock_jh): + create_skill("", template_type="markdown_only") + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args + assert call_args[0][1]["success"] is False + + def test_files_listed_in_output(self, tmp_path, capsys): + create_skill("files-test", template_type="with_handler", target_dir=tmp_path) + output = capsys.readouterr().out + assert "SKILL.md" in output + assert "handler.py" in output + + +# =================================================================== +# 3. print_introspection — module info display +# =================================================================== + + +class TestPrintIntrospection: + """Tests for print_introspection — module self-description.""" + + def test_prints_module_name(self, capsys): + print_introspection() + output = capsys.readouterr().out + assert "creator Module" in output + + def test_prints_description(self, capsys): + print_introspection() + output = capsys.readouterr().out + assert "Scaffold" in output + + def test_prints_connected_handlers(self, capsys): + print_introspection() + output = capsys.readouterr().out + assert "creator_handler.py" in output + assert "template.py" in output diff --git a/src/skills/tests/test_creator_handler.py b/src/skills/tests/test_creator_handler.py new file mode 100644 index 00000000..16ff540d --- /dev/null +++ b/src/skills/tests/test_creator_handler.py @@ -0,0 +1,171 @@ +# =================== AIPass ==================== +# Name: test_creator_handler.py +# Description: Tests for skill creation handler +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for creator_handler.py — skill name validation and create_skill logic. + +Covers: is_valid_name, create_skill (success paths, validation failures, +template failures, target_dir default, json logging). +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from skills.apps.handlers.creator_handler import create_skill, is_valid_name + + +# =================================================================== +# 1. is_valid_name — name validation +# =================================================================== + + +class TestIsValidName: + """Tests for is_valid_name — skill name validation rules.""" + + def test_simple_lowercase_name(self): + assert is_valid_name("my-skill") is True + + def test_single_letter(self): + assert is_valid_name("a") is True + + def test_lowercase_with_numbers(self): + assert is_valid_name("skill2") is True + + def test_underscores_allowed(self): + assert is_valid_name("my_skill") is True + + def test_hyphens_allowed(self): + assert is_valid_name("my-skill") is True + + def test_mixed_separators(self): + assert is_valid_name("my-skill_v2") is True + + def test_rejects_empty_string(self): + assert is_valid_name("") is False + + def test_rejects_none(self): + """None is falsy — short-circuits to False via 'not name'.""" + assert is_valid_name(None) is False + + def test_rejects_starts_with_number(self): + assert is_valid_name("2skill") is False + + def test_rejects_starts_with_hyphen(self): + assert is_valid_name("-skill") is False + + def test_rejects_uppercase(self): + assert is_valid_name("MySkill") is False + + def test_rejects_mixed_case(self): + assert is_valid_name("mySkill") is False + + def test_rejects_spaces(self): + assert is_valid_name("my skill") is False + + def test_rejects_special_chars(self): + assert is_valid_name("my.skill") is False + + def test_rejects_slash(self): + assert is_valid_name("my/skill") is False + + +# =================================================================== +# 2. create_skill — skill creation orchestration +# =================================================================== + + +class TestCreateSkill: + """Tests for create_skill — full creation pipeline.""" + + def test_create_markdown_skill_succeeds(self, tmp_path): + result = create_skill("test-md", template_type="markdown_only", target_dir=tmp_path) + assert result["success"] is True + assert result["path"] is not None + assert Path(result["path"]).exists() + assert (Path(result["path"]) / "SKILL.md").exists() + + def test_create_handler_skill_succeeds(self, tmp_path): + result = create_skill("test-hnd", template_type="with_handler", target_dir=tmp_path) + assert result["success"] is True + assert (Path(result["path"]) / "handler.py").exists() + + def test_create_full_skill_succeeds(self, tmp_path): + result = create_skill("test-full", template_type="full", target_dir=tmp_path) + assert result["success"] is True + assert (Path(result["path"]) / "apps").is_dir() + + def test_returns_created_files_list(self, tmp_path): + result = create_skill("test-files", template_type="markdown_only", target_dir=tmp_path) + assert isinstance(result["files"], list) + assert len(result["files"]) > 0 + assert "SKILL.md" in result["files"] + + def test_empty_name_fails(self): + result = create_skill("", template_type="markdown_only") + assert result["success"] is False + assert result["error"] == "Skill name is required." + assert result["path"] is None + assert result["files"] == [] + + def test_invalid_name_fails(self): + result = create_skill("Bad Name!", template_type="markdown_only") + assert result["success"] is False + assert "Invalid skill name" in result["error"] + + def test_invalid_template_type_fails(self, tmp_path): + result = create_skill("valid-name", template_type="nonexistent", target_dir=tmp_path) + assert result["success"] is False + assert "Unknown template type" in result["error"] + + def test_default_target_dir_uses_cwd(self, monkeypatch, tmp_path): + """When target_dir is None, uses CWD/.aipass/skills/.""" + monkeypatch.chdir(tmp_path) + result = create_skill("cwd-test", template_type="markdown_only") + assert result["success"] is True + expected_parent = tmp_path / ".aipass" / "skills" + assert str(expected_parent) in result["path"] + + def test_duplicate_name_fails(self, tmp_path): + """Creating a skill that already exists should fail.""" + create_skill("dupe-test", template_type="markdown_only", target_dir=tmp_path) + result = create_skill("dupe-test", template_type="markdown_only", target_dir=tmp_path) + assert result["success"] is False + assert "already exists" in result["error"] + + def test_placeholder_replacement(self, tmp_path): + """Skill name replaces {{SKILL_NAME}} in created files.""" + result = create_skill("my-replaced", template_type="markdown_only", target_dir=tmp_path) + content = (Path(result["path"]) / "SKILL.md").read_text() + assert "my-replaced" in content + assert "{{SKILL_NAME}}" not in content + + def test_logs_json_operation_on_success(self, tmp_path): + _mod = sys.modules["skills.apps.handlers.creator_handler"] + + with patch.object(_mod, "json_handler") as mock_jh: + create_skill("log-test", template_type="markdown_only", target_dir=tmp_path) + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args + assert call_args[0][0] == "skill_scaffold" + assert call_args[0][1]["success"] is True + + def test_logs_json_operation_on_failure(self, tmp_path): + _mod = sys.modules["skills.apps.handlers.creator_handler"] + + # Use a duplicate-name scenario so validation passes but copy fails, + # which is the only failure path that reaches json_handler.log_operation. + create_skill("dup-log", template_type="markdown_only", target_dir=tmp_path) + + with patch.object(_mod, "json_handler") as mock_jh: + create_skill("dup-log", template_type="markdown_only", target_dir=tmp_path) + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args + assert call_args[0][1]["success"] is False diff --git a/src/skills/tests/test_template.py b/src/skills/tests/test_template.py new file mode 100644 index 00000000..a9dd18df --- /dev/null +++ b/src/skills/tests/test_template.py @@ -0,0 +1,235 @@ +# =================== AIPass ==================== +# Name: test_template.py +# Description: Tests for skill template management +# Version: 1.0.0 +# Created: 2026-04-03 +# Modified: 2026-04-03 +# ============================================= + +""" +Tests for template.py — template resolution, placeholder replacement, copy logic. + +Covers: get_template, _replace_placeholder_in_file, copy_template +(valid/invalid types, placeholder replacement, binary skip, error paths, +target exists, cleanup on failure, __pycache__ exclusion). +""" + +import shutil +import sys +from pathlib import Path +from unittest.mock import patch + +from skills.apps.handlers.template import ( + TEMPLATES_DIR, + VALID_TYPES, + _replace_placeholder_in_file, + copy_template, + get_template, +) + + +# =================================================================== +# 1. get_template — template path resolution +# =================================================================== + + +class TestGetTemplate: + """Tests for get_template — resolve template directories.""" + + def test_markdown_only_returns_valid_path(self): + result = get_template("markdown_only") + assert result["success"] is True + assert result["path"].exists() + assert result["path"].is_dir() + assert result["error"] is None + + def test_with_handler_returns_valid_path(self): + result = get_template("with_handler") + assert result["success"] is True + assert result["path"].exists() + + def test_full_returns_valid_path(self): + result = get_template("full") + assert result["success"] is True + assert result["path"].exists() + + def test_invalid_type_fails(self): + result = get_template("bogus") + assert result["success"] is False + assert result["path"] is None + assert "Unknown template type" in result["error"] + assert "bogus" in result["error"] + + def test_error_lists_valid_types(self): + result = get_template("wrong") + for vt in VALID_TYPES: + assert vt in result["error"] + + def test_missing_directory_fails(self, monkeypatch): + """If template dir doesn't exist on disk, should fail gracefully.""" + _tpl_mod = sys.modules["skills.apps.handlers.template"] + + monkeypatch.setattr( + _tpl_mod, + "TEMPLATES_DIR", + Path("/nonexistent/templates"), + ) + result = get_template("markdown_only") + assert result["success"] is False + assert "not found" in result["error"] + + def test_templates_dir_points_to_real_directory(self): + assert TEMPLATES_DIR.exists() + assert TEMPLATES_DIR.is_dir() + + def test_all_valid_types_have_directories(self): + for vt in VALID_TYPES: + assert (TEMPLATES_DIR / vt).exists(), f"Missing template dir: {vt}" + + +# =================================================================== +# 2. _replace_placeholder_in_file — in-file substitution +# =================================================================== + + +class TestReplacePlaceholder: + """Tests for _replace_placeholder_in_file — {{SKILL_NAME}} replacement.""" + + def test_replaces_placeholder_in_text(self, tmp_path): + f = tmp_path / "test.md" + f.write_text("name: {{SKILL_NAME}}\ndesc: {{SKILL_NAME}} is great") + _replace_placeholder_in_file(f, "my-tool") + content = f.read_text() + assert "my-tool" in content + assert "{{SKILL_NAME}}" not in content + + def test_no_placeholder_leaves_file_unchanged(self, tmp_path): + f = tmp_path / "noop.txt" + original = "no placeholders here" + f.write_text(original) + _replace_placeholder_in_file(f, "anything") + assert f.read_text() == original + + def test_skips_binary_file(self, tmp_path): + """Binary files with UnicodeDecodeError should be silently skipped.""" + f = tmp_path / "binary.bin" + f.write_bytes(b"\x80\x81\x82\xff{{SKILL_NAME}}") + # Should not raise + _replace_placeholder_in_file(f, "test") + # File should still be binary (unchanged or at least not crash) + assert f.exists() + + def test_empty_file_no_error(self, tmp_path): + f = tmp_path / "empty.md" + f.write_text("") + _replace_placeholder_in_file(f, "test") + assert f.read_text() == "" + + def test_multiple_placeholders_all_replaced(self, tmp_path): + f = tmp_path / "multi.md" + f.write_text("A={{SKILL_NAME}} B={{SKILL_NAME}} C={{SKILL_NAME}}") + _replace_placeholder_in_file(f, "x") + content = f.read_text() + assert content == "A=x B=x C=x" + + +# =================================================================== +# 3. copy_template — full template copy pipeline +# =================================================================== + + +class TestCopyTemplate: + """Tests for copy_template — copy + placeholder replacement.""" + + def test_copy_markdown_template(self, tmp_path): + src = get_template("markdown_only") + target = tmp_path / "new-skill" + result = copy_template(src["path"], target, "new-skill") + assert result["success"] is True + assert target.exists() + assert len(result["created_files"]) > 0 + assert result["error"] is None + + def test_created_files_are_sorted(self, tmp_path): + src = get_template("with_handler") + target = tmp_path / "sorted-test" + result = copy_template(src["path"], target, "sorted-test") + assert result["created_files"] == sorted(result["created_files"]) + + def test_placeholders_replaced_in_all_files(self, tmp_path): + src = get_template("with_handler") + target = tmp_path / "placeholder-test" + copy_template(src["path"], target, "placeholder-test") + for f in target.rglob("*"): + if f.is_file(): + try: + content = f.read_text(encoding="utf-8") + assert "{{SKILL_NAME}}" not in content, f"Unreplaced in {f.name}" + except UnicodeDecodeError: + pass # skip binary + + def test_target_already_exists_fails(self, tmp_path): + target = tmp_path / "exists" + target.mkdir() + src = get_template("markdown_only") + result = copy_template(src["path"], target, "exists") + assert result["success"] is False + assert "already exists" in result["error"] + assert result["created_files"] == [] + + def test_invalid_source_fails(self, tmp_path): + target = tmp_path / "bad-src" + result = copy_template(Path("/nonexistent/template"), target, "bad") + assert result["success"] is False + assert "Failed to create skill" in result["error"] + + def test_cleanup_on_failure(self, tmp_path): + """If copy fails mid-way, target dir should be cleaned up.""" + target = tmp_path / "cleanup-test" + result = copy_template(Path("/nonexistent"), target, "test") + assert result["success"] is False + # Target should not exist after cleanup + assert not target.exists() + + def test_pycache_excluded(self, tmp_path): + """__pycache__ directories must not appear in output.""" + src = get_template("full") + assert src["success"] + # Inject a __pycache__ into the template temporarily + pycache = src["path"] / "__pycache__" + created = False + if not pycache.exists(): + pycache.mkdir() + (pycache / "cached.pyc").write_bytes(b"\x00") + created = True + try: + target = tmp_path / "no-cache" + result = copy_template(src["path"], target, "no-cache") + assert result["success"] is True + assert not (target / "__pycache__").exists() + for f in result["created_files"]: + assert "__pycache__" not in f + finally: + if created: + shutil.rmtree(str(pycache)) + + def test_full_template_has_apps_structure(self, tmp_path): + src = get_template("full") + target = tmp_path / "full-test" + result = copy_template(src["path"], target, "full-test") + assert result["success"] is True + assert (target / "apps").is_dir() + assert (target / "apps" / "modules").is_dir() + assert (target / "apps" / "handlers").is_dir() + + def test_logs_template_copied_operation(self, tmp_path): + _tpl_mod = sys.modules["skills.apps.handlers.template"] + + with patch.object(_tpl_mod, "json_handler") as mock_jh: + src = get_template("markdown_only") + target = tmp_path / "log-test" + copy_template(src["path"], target, "log-test") + mock_jh.log_operation.assert_called_once() + call_args = mock_jh.log_operation.call_args + assert call_args[0][0] == "template_copied" + assert call_args[0][1]["files_count"] > 0