mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
f2683291e4
Signed-off-by: phernandez <paul@basicmachines.co>
486 lines
15 KiB
Python
486 lines
15 KiB
Python
"""Tests for --format json output in CLI tool commands.
|
|
|
|
Verifies that write-note, read-note, and recent-activity commands
|
|
produce valid JSON output when invoked with --format json, and that
|
|
the default text format still works via the MCP tool path.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from basic_memory.cli.main import app as cli_app
|
|
|
|
runner = CliRunner()
|
|
|
|
# --- Shared mock data ---
|
|
|
|
WRITE_NOTE_RESULT = {
|
|
"title": "Test Note",
|
|
"permalink": "notes/test-note",
|
|
"content": "hello world",
|
|
"file_path": "notes/Test Note.md",
|
|
}
|
|
|
|
READ_NOTE_RESULT = {
|
|
"title": "Test Note",
|
|
"permalink": "notes/test-note",
|
|
"content": "---\ntitle: Test Note\ntags:\n- test\n---\n# Test Note\n\nhello world",
|
|
"file_path": "notes/Test Note.md",
|
|
}
|
|
|
|
RECENT_ACTIVITY_RESULT = [
|
|
{
|
|
"title": "Note A",
|
|
"permalink": "notes/note-a",
|
|
"file_path": "notes/Note A.md",
|
|
"created_at": "2025-01-01 00:00:00",
|
|
},
|
|
{
|
|
"title": "Note B",
|
|
"permalink": "notes/note-b",
|
|
"file_path": "notes/Note B.md",
|
|
"created_at": "2025-01-02 00:00:00",
|
|
},
|
|
]
|
|
|
|
|
|
def _mock_config_manager():
|
|
"""Create a mock ConfigManager that avoids reading real config."""
|
|
mock_cm = MagicMock()
|
|
mock_cm.config = MagicMock()
|
|
mock_cm.default_project = "test-project"
|
|
mock_cm.get_project.return_value = ("test-project", "/tmp/test")
|
|
return mock_cm
|
|
|
|
|
|
# --- write-note --format json ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._write_note_json",
|
|
new_callable=AsyncMock,
|
|
return_value=WRITE_NOTE_RESULT,
|
|
)
|
|
def test_write_note_json_output(mock_write_json, mock_config_cls):
|
|
"""write-note --format json outputs valid JSON with expected keys."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
[
|
|
"tool",
|
|
"write-note",
|
|
"--title",
|
|
"Test Note",
|
|
"--folder",
|
|
"notes",
|
|
"--content",
|
|
"hello world",
|
|
"--format",
|
|
"json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data["title"] == "Test Note"
|
|
assert data["permalink"] == "notes/test-note"
|
|
assert data["content"] == "hello world"
|
|
assert data["file_path"] == "notes/Test Note.md"
|
|
mock_write_json.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool.mcp_write_note",
|
|
)
|
|
def test_write_note_text_output(mock_mcp_write, mock_config_cls):
|
|
"""write-note with default text format uses the MCP tool path."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
# MCP tool .fn returns a formatted string
|
|
mock_mcp_write.fn = AsyncMock(return_value="Created note: Test Note")
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
[
|
|
"tool",
|
|
"write-note",
|
|
"--title",
|
|
"Test Note",
|
|
"--folder",
|
|
"notes",
|
|
"--content",
|
|
"hello world",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "Created note: Test Note" in result.output
|
|
mock_mcp_write.fn.assert_called_once()
|
|
|
|
|
|
# --- read-note --format json ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._read_note_json",
|
|
new_callable=AsyncMock,
|
|
return_value=READ_NOTE_RESULT,
|
|
)
|
|
def test_read_note_json_output(mock_read_json, mock_config_cls):
|
|
"""read-note --format json outputs valid JSON with expected keys."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--format", "json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data["title"] == "Test Note"
|
|
assert data["permalink"] == "notes/test-note"
|
|
assert (
|
|
data["content"] == "---\ntitle: Test Note\ntags:\n- test\n---\n# Test Note\n\nhello world"
|
|
)
|
|
assert data["frontmatter"] == {"title": "Test Note", "tags": ["test"]}
|
|
assert data["file_path"] == "notes/Test Note.md"
|
|
mock_read_json.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool.mcp_read_note",
|
|
)
|
|
def test_read_note_text_output(mock_mcp_read, mock_config_cls):
|
|
"""read-note with default text format uses the MCP tool path."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
mock_mcp_read.fn = AsyncMock(
|
|
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world"
|
|
)
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "---" in result.output
|
|
mock_mcp_read.fn.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch("basic_memory.cli.commands.tool.mcp_read_note")
|
|
def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
|
|
"""read-note --workspace passes workspace through to the MCP tool call."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
mock_mcp_read.fn = AsyncMock(return_value="# Test Note")
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--workspace", "tenant-123"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
mock_mcp_read.fn.assert_called_once()
|
|
assert mock_mcp_read.fn.call_args.kwargs["workspace"] == "tenant-123"
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._read_note_json",
|
|
new_callable=AsyncMock,
|
|
return_value=READ_NOTE_RESULT,
|
|
)
|
|
def test_read_note_json_strip_frontmatter(mock_read_json, mock_config_cls):
|
|
"""read-note --format json --strip-frontmatter strips content but keeps frontmatter object."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--format", "json", "--strip-frontmatter"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data["title"] == "Test Note"
|
|
assert data["permalink"] == "notes/test-note"
|
|
assert data["content"] == "# Test Note\n\nhello world"
|
|
assert data["frontmatter"] == {"title": "Test Note", "tags": ["test"]}
|
|
assert data["file_path"] == "notes/Test Note.md"
|
|
mock_read_json.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool.mcp_read_note",
|
|
)
|
|
def test_read_note_text_strip_frontmatter(mock_mcp_read, mock_config_cls):
|
|
"""read-note --strip-frontmatter strips opening frontmatter in text mode."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
mock_mcp_read.fn = AsyncMock(
|
|
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world"
|
|
)
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--strip-frontmatter"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "---" not in result.output
|
|
assert "# Test Note" in result.output
|
|
mock_mcp_read.fn.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool.mcp_read_note",
|
|
)
|
|
def test_read_note_text_strip_frontmatter_no_frontmatter(mock_mcp_read, mock_config_cls):
|
|
"""read-note --strip-frontmatter keeps notes unchanged when no frontmatter exists."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
mock_mcp_read.fn = AsyncMock(return_value="# Test Note\n\nhello world")
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--strip-frontmatter"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert result.output.strip() == "# Test Note\n\nhello world"
|
|
mock_mcp_read.fn.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._read_note_json",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"title": "Test Note",
|
|
"permalink": "notes/test-note",
|
|
"content": "---\ntitle: [bad yaml\n# Test Note\n\nhello world",
|
|
"file_path": "notes/Test Note.md",
|
|
},
|
|
)
|
|
def test_read_note_json_malformed_frontmatter_kept(mock_read_json, mock_config_cls):
|
|
"""Malformed opening frontmatter should remain unchanged with frontmatter set to null."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--format", "json", "--strip-frontmatter"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data["content"] == "---\ntitle: [bad yaml\n# Test Note\n\nhello world"
|
|
assert data["frontmatter"] is None
|
|
mock_read_json.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._read_note_json",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"title": "No Frontmatter Note",
|
|
"permalink": "notes/no-frontmatter-note",
|
|
"content": "# No Frontmatter Note\n\nhello world",
|
|
"file_path": "notes/No Frontmatter Note.md",
|
|
},
|
|
)
|
|
def test_read_note_json_strip_frontmatter_no_frontmatter(mock_read_json, mock_config_cls):
|
|
"""JSON strip mode should keep content unchanged when no frontmatter exists."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "test-note", "--format", "json", "--strip-frontmatter"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data["content"] == "# No Frontmatter Note\n\nhello world"
|
|
assert data["frontmatter"] is None
|
|
mock_read_json.assert_called_once()
|
|
|
|
|
|
# --- recent-activity --format json ---
|
|
|
|
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._recent_activity_json",
|
|
new_callable=AsyncMock,
|
|
return_value=RECENT_ACTIVITY_RESULT,
|
|
)
|
|
def test_recent_activity_json_output(mock_recent_json):
|
|
"""recent-activity --format json outputs valid JSON list."""
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "recent-activity", "--format", "json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert isinstance(data, list)
|
|
assert len(data) == 2
|
|
assert data[0]["title"] == "Note A"
|
|
assert data[0]["permalink"] == "notes/note-a"
|
|
assert data[0]["file_path"] == "notes/Note A.md"
|
|
assert data[0]["created_at"] == "2025-01-01 00:00:00"
|
|
assert data[1]["title"] == "Note B"
|
|
mock_recent_json.assert_called_once()
|
|
|
|
|
|
@patch(
|
|
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
|
)
|
|
def test_recent_activity_text_output(mock_mcp_recent):
|
|
"""recent-activity with default text format uses the MCP tool path."""
|
|
mock_mcp_recent.fn = AsyncMock(return_value="Recent activity:\n- Note A\n- Note B")
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "recent-activity"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "Recent activity:" in result.output
|
|
mock_mcp_recent.fn.assert_called_once()
|
|
|
|
|
|
# --- read-note title fallback ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._read_note_json",
|
|
new_callable=AsyncMock,
|
|
return_value=READ_NOTE_RESULT,
|
|
)
|
|
def test_read_note_json_with_plain_title(mock_read_json, mock_config_cls):
|
|
"""read-note --format json works with plain titles (not just permalinks)."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "read-note", "My Note Title", "--format", "json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data["title"] == "Test Note"
|
|
# Verify the identifier was passed through
|
|
call_args = mock_read_json.call_args
|
|
assert call_args[0][0] == "My Note Title" or call_args[1].get("identifier") == "My Note Title"
|
|
|
|
|
|
# --- recent-activity pagination ---
|
|
|
|
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._recent_activity_json",
|
|
new_callable=AsyncMock,
|
|
return_value=RECENT_ACTIVITY_RESULT,
|
|
)
|
|
def test_recent_activity_json_pagination(mock_recent_json):
|
|
"""recent-activity --format json passes --page and --page-size to helper."""
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "recent-activity", "--format", "json", "--page", "2", "--page-size", "10"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert isinstance(data, list)
|
|
# Verify pagination params were passed through
|
|
mock_recent_json.assert_called_once()
|
|
call_kwargs = mock_recent_json.call_args.kwargs
|
|
assert call_kwargs["page"] == 2
|
|
assert call_kwargs["page_size"] == 10
|
|
|
|
|
|
# --- build-context --format json ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch("basic_memory.cli.commands.tool.mcp_build_context")
|
|
def test_build_context_format_json(mock_build_ctx, mock_config_cls):
|
|
"""build-context --format json outputs valid JSON."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
# build_context now returns a slimmed dict directly
|
|
mock_build_ctx.fn = AsyncMock(
|
|
return_value={
|
|
"results": [],
|
|
"metadata": {"uri": "test/topic", "depth": 1},
|
|
"page": 1,
|
|
"page_size": 10,
|
|
}
|
|
)
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "build-context", "memory://test/topic", "--format", "json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert "results" in data
|
|
mock_build_ctx.fn.assert_called_once()
|
|
|
|
|
|
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
|
@patch("basic_memory.cli.commands.tool.mcp_build_context")
|
|
def test_build_context_default_format_is_json(mock_build_ctx, mock_config_cls):
|
|
"""build-context defaults to JSON output (backward compatible)."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
# build_context now returns a slimmed dict directly
|
|
mock_build_ctx.fn = AsyncMock(
|
|
return_value={
|
|
"results": [],
|
|
"metadata": {"uri": "test/topic", "depth": 1},
|
|
"page": 1,
|
|
"page_size": 10,
|
|
}
|
|
)
|
|
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "build-context", "memory://test/topic"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert isinstance(data, dict)
|
|
|
|
|
|
# --- Edge cases ---
|
|
|
|
|
|
@patch(
|
|
"basic_memory.cli.commands.tool._recent_activity_json",
|
|
new_callable=AsyncMock,
|
|
return_value=[],
|
|
)
|
|
def test_recent_activity_json_empty(mock_recent_json):
|
|
"""recent-activity --format json handles empty results."""
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["tool", "recent-activity", "--format", "json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
data = json.loads(result.output)
|
|
assert data == []
|