Files
basicmachines-co-basic-memory/tests/cli/test_orphans_command.py
T
claude[bot] d8cb7313db feat(cli): add orphans command to show entities without relations
Adds a new `basic-memory orphans` CLI command that identifies entities
with no incoming or outgoing connections in the knowledge graph, helping
users discover isolated notes that haven't been linked into their knowledge base.

Changes:
- EntityRepository.find_without_relations(): efficient SQL EXISTS subquery
- GET /v2/projects/{id}/knowledge/orphans API endpoint
- OrphanEntitiesResponse schema (entities list + total count)
- KnowledgeClient.get_orphans() typed API client method
- `basic-memory orphans` CLI command with table and --json output
- Tests for repository method, API endpoint, and CLI command

Closes #762

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-23 02:13:55 +00:00

113 lines
4.1 KiB
Python

"""Tests for the 'basic-memory orphans' CLI command."""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
# Register the orphans command on the shared app.
import basic_memory.cli.commands.orphans as orphans_cmd # noqa: F401
runner = CliRunner()
_MOCK_PROJECT_ITEM = MagicMock()
_MOCK_PROJECT_ITEM.name = "test-project"
_MOCK_PROJECT_ITEM.external_id = "11111111-1111-1111-1111-111111111111"
_ORPHAN_ENTITIES = [
{
"external_id": "aaaa-1111",
"title": "Isolated Note",
"file_path": "notes/isolated.md",
"note_type": "note",
},
{
"external_id": "bbbb-2222",
"title": "Dangling Spec",
"file_path": "specs/dangling.md",
"note_type": "spec",
},
]
def _mock_config_manager():
mock_cm = MagicMock()
mock_cm.default_project = "test-project"
return mock_cm
@asynccontextmanager
async def _fake_get_client(project_name=None):
yield MagicMock()
@patch("basic_memory.cli.commands.orphans.ConfigManager")
@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.get_client")
@patch("basic_memory.cli.commands.orphans.KnowledgeClient")
def test_orphans_json_output(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls):
"""bm orphans --json outputs a JSON array of entity objects."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
mock_get_client.side_effect = _fake_get_client
mock_knowledge_instance = AsyncMock()
mock_knowledge_instance.get_orphans.return_value = _ORPHAN_ENTITIES
mock_knowledge_cls.return_value = mock_knowledge_instance
result = runner.invoke(cli_app, ["orphans", "--json"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
start = result.output.index("[")
data = json.loads(result.output[start:])
assert len(data) == 2
titles = {e["title"] for e in data}
assert "Isolated Note" in titles
assert "Dangling Spec" in titles
@patch("basic_memory.cli.commands.orphans.ConfigManager")
@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.get_client")
@patch("basic_memory.cli.commands.orphans.KnowledgeClient")
def test_orphans_table_output(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls):
"""bm orphans (default) renders a Rich table with titles and paths."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
mock_get_client.side_effect = _fake_get_client
mock_knowledge_instance = AsyncMock()
mock_knowledge_instance.get_orphans.return_value = _ORPHAN_ENTITIES
mock_knowledge_cls.return_value = mock_knowledge_instance
result = runner.invoke(cli_app, ["orphans"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "Isolated Note" in result.output
assert "Dangling Spec" in result.output
assert "notes/isolated.md" in result.output
@patch("basic_memory.cli.commands.orphans.ConfigManager")
@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.orphans.get_client")
@patch("basic_memory.cli.commands.orphans.KnowledgeClient")
def test_orphans_no_results(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls):
"""bm orphans prints a success message when no orphans are found."""
mock_config_cls.return_value = _mock_config_manager()
mock_get_active.return_value = _MOCK_PROJECT_ITEM
mock_get_client.side_effect = _fake_get_client
mock_knowledge_instance = AsyncMock()
mock_knowledge_instance.get_orphans.return_value = []
mock_knowledge_cls.return_value = mock_knowledge_instance
result = runner.invoke(cli_app, ["orphans"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "No orphan entities" in result.output