mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
94e2c92805
Refactor CLI tool commands to eliminate code duplication and improve testability by extracting shared utilities. Changes: - Create tool_utils.py with resolve_project() and run_async_tool() - Refactor all 6 CLI tool commands to use new helpers - Add project parameter to recent_activity for consistency - Fix search_notes search_type logic bug (was overwriting itself) - Add comprehensive unit tests for tool_utils - Add cloud mode routing and auth tests Benefits: - 32% code reduction (342 → 234 lines in tool.py) - Consistent error handling across all commands - Better testability with extracted utilities - Cloud mode functionality validated Fixes #346 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
"""Tests for CLI tools in cloud mode.
|
|
|
|
These tests verify that CLI tools properly route to cloud endpoints
|
|
and inject authentication headers when in cloud mode.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from basic_memory.cli.commands.tool import tool_app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_cloud_config(tmp_path):
|
|
"""Mock cloud configuration."""
|
|
config_dir = tmp_path / ".basic-memory"
|
|
config_dir.mkdir(parents=True)
|
|
|
|
# Create mock auth file
|
|
auth_file = config_dir / "auth.json"
|
|
auth_file.write_text('{"access_token": "test-token", "refresh_token": "test-refresh"}')
|
|
|
|
# Create mock config file with cloud project
|
|
config_file = config_dir / "config.yaml"
|
|
config_file.write_text("""
|
|
projects:
|
|
test-cloud:
|
|
path: /tmp/test-cloud
|
|
mode: cloud
|
|
cloud_project_id: test-project-123
|
|
default_project: test-cloud
|
|
""")
|
|
|
|
with patch("basic_memory.config.get_config_dir", return_value=config_dir):
|
|
yield config_dir
|
|
|
|
|
|
class TestCloudModeRouting:
|
|
"""Tests for cloud mode routing and authentication."""
|
|
|
|
def test_write_note_routes_to_cloud(self, mock_cloud_config):
|
|
"""Test that write_note routes to cloud endpoint in cloud mode."""
|
|
# Mock the HTTP client to capture the request
|
|
mock_response = Mock(spec=httpx.Response)
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"title": "Test Note",
|
|
"permalink": "test-note",
|
|
"status": "Created",
|
|
}
|
|
|
|
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.post = AsyncMock(return_value=mock_response)
|
|
mock_client.return_value.__aenter__.return_value = mock_instance
|
|
|
|
# Run command
|
|
result = runner.invoke(
|
|
tool_app,
|
|
[
|
|
"write-note",
|
|
"--title", "Test Note",
|
|
"--content", "Test content",
|
|
"--folder", "test",
|
|
],
|
|
)
|
|
|
|
# Verify cloud endpoint was called
|
|
# In cloud mode, requests should go through /proxy endpoint
|
|
assert mock_instance.post.called or mock_instance.request.called
|
|
|
|
# Verify auth headers were injected
|
|
if mock_instance.post.called:
|
|
call_kwargs = mock_instance.post.call_args.kwargs
|
|
assert "headers" in call_kwargs
|
|
# Auth is injected at client creation, not per-request
|
|
|
|
def test_search_notes_cloud_auth_injection(self, mock_cloud_config):
|
|
"""Test that search_notes injects auth headers in cloud mode."""
|
|
mock_response = Mock(spec=httpx.Response)
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"results": [],
|
|
"metadata": {"total_results": 0},
|
|
}
|
|
|
|
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get = AsyncMock(return_value=mock_response)
|
|
mock_client.return_value.__aenter__.return_value = mock_instance
|
|
|
|
# Run command
|
|
result = runner.invoke(
|
|
tool_app,
|
|
["search-notes", "test query"],
|
|
)
|
|
|
|
# Verify cloud endpoint was called with auth
|
|
assert mock_instance.get.called or mock_instance.request.called
|
|
|
|
def test_read_note_cloud_mode(self, mock_cloud_config):
|
|
"""Test that read_note works in cloud mode."""
|
|
mock_response = Mock(spec=httpx.Response)
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"title": "Test Note",
|
|
"permalink": "test-note",
|
|
"content": "Test content",
|
|
}
|
|
|
|
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get = AsyncMock(return_value=mock_response)
|
|
mock_client.return_value.__aenter__.return_value = mock_instance
|
|
|
|
# Run command
|
|
result = runner.invoke(
|
|
tool_app,
|
|
["read-note", "test-note"],
|
|
)
|
|
|
|
# Verify cloud endpoint was called
|
|
assert mock_instance.get.called or mock_instance.request.called
|
|
|
|
|
|
class TestCloudModeErrors:
|
|
"""Tests for cloud mode error handling."""
|
|
|
|
def test_unauthenticated_error(self, tmp_path):
|
|
"""Test error handling when not authenticated in cloud mode."""
|
|
config_dir = tmp_path / ".basic-memory"
|
|
config_dir.mkdir(parents=True)
|
|
|
|
# Create config WITHOUT auth file
|
|
config_file = config_dir / "config.yaml"
|
|
config_file.write_text("""
|
|
projects:
|
|
test-cloud:
|
|
path: /tmp/test-cloud
|
|
mode: cloud
|
|
cloud_project_id: test-project-123
|
|
default_project: test-cloud
|
|
""")
|
|
|
|
with patch("basic_memory.config.get_config_dir", return_value=config_dir):
|
|
# Mock client to raise authentication error
|
|
mock_response = Mock(spec=httpx.Response)
|
|
mock_response.status_code = 401
|
|
mock_response.json.return_value = {"detail": "Unauthorized"}
|
|
|
|
http_error = httpx.HTTPStatusError(
|
|
"401 Unauthorized",
|
|
request=Mock(),
|
|
response=mock_response,
|
|
)
|
|
|
|
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get = AsyncMock(side_effect=http_error)
|
|
mock_client.return_value.__aenter__.return_value = mock_instance
|
|
|
|
# Run command - should handle error gracefully
|
|
result = runner.invoke(
|
|
tool_app,
|
|
["read-note", "test-note"],
|
|
)
|
|
|
|
# Command should exit with error
|
|
assert result.exit_code == 1
|
|
|
|
def test_subscription_required_error(self, mock_cloud_config):
|
|
"""Test handling of subscription required error."""
|
|
mock_response = Mock(spec=httpx.Response)
|
|
mock_response.status_code = 403
|
|
mock_response.json.return_value = {
|
|
"detail": {
|
|
"error": "subscription_required",
|
|
"message": "Active subscription required",
|
|
"subscribe_url": "https://basicmemory.com/subscribe",
|
|
}
|
|
}
|
|
|
|
http_error = httpx.HTTPStatusError(
|
|
"403 Forbidden",
|
|
request=Mock(),
|
|
response=mock_response,
|
|
)
|
|
|
|
with patch("basic_memory.mcp.async_client.httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.post = AsyncMock(side_effect=http_error)
|
|
mock_client.return_value.__aenter__.return_value = mock_instance
|
|
|
|
# Run command
|
|
result = runner.invoke(
|
|
tool_app,
|
|
[
|
|
"write-note",
|
|
"--title", "Test",
|
|
"--content", "Test",
|
|
"--folder", "test",
|
|
],
|
|
)
|
|
|
|
# Command should exit with error
|
|
assert result.exit_code == 1
|