feat(system): add S73 test coverage files for 12 branches (#184)

* feat(system): feat(system): FPLAN-0164 dispatch safety net + morning briefing timeout fix

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): fix(drone+backup): resolver BranchNotFoundError handling + backup snapshot quick-check

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): fix(ai_mail): test fix for JSONL-based startup detection in dispatch_monitor

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): feat(daemon): expand test coverage from 12% — 74 new tests for scheduler_cron and actions

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): S73 night shift: test coverage push to 100% + seedgo test depth fixes

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): README overhaul + HERALD update + S73 test files

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): S73 night shift: 51 new test files across 12 branches

Co-Authored-By: @devpulse <devpulse@aipass>

---------

Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
AIPass
2026-04-05 11:56:18 -07:00
committed by GitHub
co-authored by @devpulse
parent 6194c751dc
commit 38026271ef
50 changed files with 22074 additions and 0 deletions
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
+196
View File
@@ -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
+86
View File
@@ -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
+91
View File
@@ -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()
@@ -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}"
+354
View File
@@ -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
@@ -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"
@@ -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
+183
View File
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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)
+249
View File
@@ -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)
@@ -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"
@@ -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"}
)
File diff suppressed because it is too large Load Diff
@@ -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
+761
View File
@@ -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
+556
View File
@@ -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"] == []
@@ -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"] != ""
@@ -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
+691
View File
@@ -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
+662
View File
@@ -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
+577
View File
@@ -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
+359
View File
@@ -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()
+248
View File
@@ -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(
"<<<broken>>>", 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]
+449
View File
@@ -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
+628
View File
@@ -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()
@@ -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
+339
View File
@@ -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("<<<not json>>>", 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 == {}
+333
View File
@@ -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
+450
View File
@@ -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()
@@ -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)
@@ -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
@@ -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()
+340
View File
@@ -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
@@ -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/<branch>/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
@@ -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"
@@ -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
+714
View File
@@ -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
File diff suppressed because it is too large Load Diff
+812
View File
@@ -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"
+164
View File
@@ -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
+171
View File
@@ -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
+235
View File
@@ -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