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:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == {}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user