mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
8df88e4d02
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Tests for the watch CLI command."""
|
|
|
|
import asyncio
|
|
import os
|
|
import signal
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from basic_memory.cli.commands.watch import run_watch
|
|
from basic_memory.config import BasicMemoryConfig
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config():
|
|
"""Create a mock config for testing."""
|
|
return BasicMemoryConfig()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_container(mock_config):
|
|
"""Create a mock CLI container."""
|
|
container = MagicMock()
|
|
container.config = mock_config
|
|
return container
|
|
|
|
|
|
class TestRunWatch:
|
|
"""Tests for run_watch async function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initializes_app(self, mock_container):
|
|
"""run_watch calls initialize_app with the container's config."""
|
|
mock_init = AsyncMock()
|
|
|
|
with (
|
|
patch("basic_memory.cli.commands.watch.get_container", return_value=mock_container),
|
|
patch("basic_memory.cli.commands.watch.initialize_app", mock_init),
|
|
patch("basic_memory.cli.commands.watch.SyncCoordinator") as mock_coordinator_cls,
|
|
patch("basic_memory.cli.commands.watch.db") as mock_db,
|
|
):
|
|
# Make coordinator.start() set the shutdown event so we don't block
|
|
mock_coordinator = AsyncMock()
|
|
mock_coordinator_cls.return_value = mock_coordinator
|
|
|
|
async def start_then_shutdown():
|
|
# Simulate immediate shutdown after start
|
|
pass
|
|
|
|
mock_coordinator.start = start_then_shutdown
|
|
mock_coordinator.stop = AsyncMock()
|
|
mock_db.shutdown_db = AsyncMock()
|
|
|
|
# Patch signal handlers and make shutdown_event fire immediately
|
|
with patch("asyncio.get_running_loop") as mock_loop:
|
|
mock_loop_instance = MagicMock()
|
|
mock_loop.return_value = mock_loop_instance
|
|
|
|
# Capture the signal handler so we can trigger it
|
|
signal_handlers = {}
|
|
|
|
def capture_handler(sig, handler):
|
|
signal_handlers[sig] = handler
|
|
|
|
mock_loop_instance.add_signal_handler.side_effect = capture_handler
|
|
|
|
# Run in a task so we can trigger shutdown
|
|
async def run_and_shutdown():
|
|
task = asyncio.create_task(run_watch())
|
|
# Give it a moment to start
|
|
await asyncio.sleep(0.01)
|
|
# Trigger shutdown via captured signal handler
|
|
import signal
|
|
|
|
if signal.SIGINT in signal_handlers:
|
|
signal_handlers[signal.SIGINT]()
|
|
await task
|
|
|
|
await run_and_shutdown()
|
|
|
|
mock_init.assert_called_once_with(mock_container.config)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creates_coordinator_with_quiet_false(self, mock_container):
|
|
"""SyncCoordinator is created with should_sync=True and quiet=False."""
|
|
with (
|
|
patch("basic_memory.cli.commands.watch.get_container", return_value=mock_container),
|
|
patch("basic_memory.cli.commands.watch.initialize_app", AsyncMock()),
|
|
patch("basic_memory.cli.commands.watch.SyncCoordinator") as mock_coordinator_cls,
|
|
patch("basic_memory.cli.commands.watch.db") as mock_db,
|
|
):
|
|
mock_coordinator = AsyncMock()
|
|
mock_coordinator_cls.return_value = mock_coordinator
|
|
mock_db.shutdown_db = AsyncMock()
|
|
|
|
with patch("asyncio.get_running_loop") as mock_loop:
|
|
mock_loop_instance = MagicMock()
|
|
mock_loop.return_value = mock_loop_instance
|
|
|
|
signal_handlers = {}
|
|
|
|
def capture_handler(sig, handler):
|
|
signal_handlers[sig] = handler
|
|
|
|
mock_loop_instance.add_signal_handler.side_effect = capture_handler
|
|
|
|
async def run_and_shutdown():
|
|
task = asyncio.create_task(run_watch())
|
|
await asyncio.sleep(0.01)
|
|
import signal
|
|
|
|
if signal.SIGINT in signal_handlers:
|
|
signal_handlers[signal.SIGINT]()
|
|
await task
|
|
|
|
await run_and_shutdown()
|
|
|
|
mock_coordinator_cls.assert_called_once_with(
|
|
config=mock_container.config,
|
|
should_sync=True,
|
|
quiet=False,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_project_sets_env_var(self, mock_container):
|
|
"""--project validates and sets BASIC_MEMORY_MCP_PROJECT env var."""
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.get_project.return_value = ("my-project", "/some/path")
|
|
|
|
with (
|
|
patch("basic_memory.cli.commands.watch.get_container", return_value=mock_container),
|
|
patch("basic_memory.cli.commands.watch.initialize_app", AsyncMock()),
|
|
patch(
|
|
"basic_memory.cli.commands.watch.ConfigManager",
|
|
return_value=mock_config_manager,
|
|
),
|
|
patch("basic_memory.cli.commands.watch.SyncCoordinator") as mock_coordinator_cls,
|
|
patch("basic_memory.cli.commands.watch.db") as mock_db,
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
mock_coordinator = AsyncMock()
|
|
mock_coordinator_cls.return_value = mock_coordinator
|
|
mock_db.shutdown_db = AsyncMock()
|
|
|
|
with patch("asyncio.get_running_loop") as mock_loop:
|
|
mock_loop_instance = MagicMock()
|
|
mock_loop.return_value = mock_loop_instance
|
|
|
|
signal_handlers = {}
|
|
|
|
def capture_handler(sig, handler):
|
|
signal_handlers[sig] = handler
|
|
|
|
mock_loop_instance.add_signal_handler.side_effect = capture_handler
|
|
|
|
async def run_and_shutdown():
|
|
task = asyncio.create_task(run_watch(project="my-project"))
|
|
await asyncio.sleep(0.01)
|
|
import signal
|
|
|
|
if signal.SIGINT in signal_handlers:
|
|
signal_handlers[signal.SIGINT]()
|
|
await task
|
|
|
|
await run_and_shutdown()
|
|
|
|
assert os.environ.get("BASIC_MEMORY_MCP_PROJECT") == "my-project"
|
|
|
|
# Clean up env var
|
|
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_project_exits_with_error(self, mock_container):
|
|
"""--project with unknown name exits with error and still cleans up DB."""
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.get_project.return_value = (None, None)
|
|
|
|
with (
|
|
patch("basic_memory.cli.commands.watch.get_container", return_value=mock_container),
|
|
patch("basic_memory.cli.commands.watch.initialize_app", AsyncMock()),
|
|
patch(
|
|
"basic_memory.cli.commands.watch.ConfigManager",
|
|
return_value=mock_config_manager,
|
|
),
|
|
patch("basic_memory.cli.commands.watch.db") as mock_db,
|
|
):
|
|
mock_db.shutdown_db = AsyncMock()
|
|
|
|
with pytest.raises(typer.Exit) as exc_info:
|
|
await run_watch(project="nonexistent")
|
|
|
|
assert exc_info.value.exit_code == 1
|
|
# DB should still be cleaned up even on early exit
|
|
mock_db.shutdown_db.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_stops_coordinator_and_db(self, mock_container):
|
|
"""On shutdown, coordinator.stop() and db.shutdown_db() are called."""
|
|
with (
|
|
patch("basic_memory.cli.commands.watch.get_container", return_value=mock_container),
|
|
patch("basic_memory.cli.commands.watch.initialize_app", AsyncMock()),
|
|
patch("basic_memory.cli.commands.watch.SyncCoordinator") as mock_coordinator_cls,
|
|
patch("basic_memory.cli.commands.watch.db") as mock_db,
|
|
):
|
|
mock_coordinator = AsyncMock()
|
|
mock_coordinator_cls.return_value = mock_coordinator
|
|
mock_db.shutdown_db = AsyncMock()
|
|
|
|
with patch("asyncio.get_running_loop") as mock_loop:
|
|
mock_loop_instance = MagicMock()
|
|
mock_loop.return_value = mock_loop_instance
|
|
|
|
signal_handlers = {}
|
|
|
|
def capture_handler(sig, handler):
|
|
signal_handlers[sig] = handler
|
|
|
|
mock_loop_instance.add_signal_handler.side_effect = capture_handler
|
|
|
|
async def run_and_shutdown():
|
|
task = asyncio.create_task(run_watch())
|
|
await asyncio.sleep(0.01)
|
|
import signal
|
|
|
|
if signal.SIGINT in signal_handlers:
|
|
signal_handlers[signal.SIGINT]()
|
|
await task
|
|
|
|
await run_and_shutdown()
|
|
|
|
mock_coordinator.stop.assert_called_once()
|
|
mock_db.shutdown_db.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_signal_module_on_windows(self, mock_container):
|
|
"""When add_signal_handler raises NotImplementedError, falls back to signal.signal()."""
|
|
with (
|
|
patch("basic_memory.cli.commands.watch.get_container", return_value=mock_container),
|
|
patch("basic_memory.cli.commands.watch.initialize_app", AsyncMock()),
|
|
patch("basic_memory.cli.commands.watch.SyncCoordinator") as mock_coordinator_cls,
|
|
patch("basic_memory.cli.commands.watch.db") as mock_db,
|
|
):
|
|
mock_coordinator = AsyncMock()
|
|
mock_coordinator_cls.return_value = mock_coordinator
|
|
mock_db.shutdown_db = AsyncMock()
|
|
|
|
with patch("asyncio.get_running_loop") as mock_loop:
|
|
mock_loop_instance = MagicMock()
|
|
mock_loop.return_value = mock_loop_instance
|
|
|
|
# Simulate Windows: add_signal_handler raises NotImplementedError
|
|
mock_loop_instance.add_signal_handler.side_effect = NotImplementedError
|
|
|
|
with patch("basic_memory.cli.commands.watch.signal.signal") as mock_signal:
|
|
# Track calls to signal.signal for the fallback path
|
|
registered_handlers = {}
|
|
|
|
def capture_signal(sig, handler):
|
|
registered_handlers[sig] = handler
|
|
|
|
mock_signal.side_effect = capture_signal
|
|
|
|
async def run_and_shutdown():
|
|
task = asyncio.create_task(run_watch())
|
|
await asyncio.sleep(0.01)
|
|
# Trigger shutdown via the fallback handler
|
|
if signal.SIGINT in registered_handlers:
|
|
registered_handlers[signal.SIGINT](signal.SIGINT, None)
|
|
await task
|
|
|
|
await run_and_shutdown()
|
|
|
|
# Verify fallback signal.signal was called for both signals
|
|
assert mock_signal.call_count == 2
|
|
called_signals = {call.args[0] for call in mock_signal.call_args_list}
|
|
assert signal.SIGINT in called_signals
|
|
assert signal.SIGTERM in called_signals
|