mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
2e5813d31e
Refactor CLI commands to use typed ProjectClient instead of raw HTTP calls, and add workspace metadata to cloud project listings so users can distinguish personal vs organization projects. Key changes: - 🔧 CLI commands now use ProjectClient typed API clients instead of call_get/call_post with manual URL construction - 🏢 Cloud project listings include workspace_name, workspace_type, and workspace_tenant_id for each cloud-sourced project - Pass config.default_workspace when fetching cloud projects via _fetch_cloud_projects() and CLI list_projects - Add --workspace flag to `bm project list` for explicit workspace override - Add "Workspace" column to CLI project list table - Add `bm tool list-projects` and `bm tool list-workspaces` JSON commands - Comprehensive tests for workspace passthrough, merge behavior, and CLI routing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
"""Tests for bm project add with --local-path flag."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from contextlib import asynccontextmanager
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from basic_memory.cli.app import app
|
|
from basic_memory.mcp.clients.project import ProjectClient
|
|
from basic_memory.schemas.project_info import ProjectStatusResponse
|
|
|
|
# Importing registers project subcommands on the shared app instance.
|
|
import basic_memory.cli.commands.project as project_cmd # noqa: F401
|
|
|
|
|
|
@pytest.fixture
|
|
def runner():
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config(tmp_path, monkeypatch):
|
|
"""Create a mock config with cloud credentials using environment variables."""
|
|
# Invalidate config cache to ensure clean state for each test
|
|
from basic_memory import config as config_module
|
|
|
|
config_module._CONFIG_CACHE = None
|
|
|
|
config_dir = tmp_path / ".basic-memory"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
config_file = config_dir / "config.json"
|
|
|
|
config_data = {
|
|
"env": "dev",
|
|
"projects": {},
|
|
"default_project": "main",
|
|
"cloud_api_key": "bmc_test_key_123",
|
|
}
|
|
|
|
config_file.write_text(json.dumps(config_data, indent=2))
|
|
|
|
# Set HOME to tmp_path so ConfigManager uses our test config
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
|
|
yield config_file
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_api_client(monkeypatch):
|
|
"""Stub the API client for project add without stdlib mocks."""
|
|
|
|
@asynccontextmanager
|
|
async def fake_get_client():
|
|
yield object()
|
|
|
|
_response_data = {
|
|
"message": "Project 'test-project' added successfully",
|
|
"status": "success",
|
|
"default": False,
|
|
"old_project": None,
|
|
"new_project": {
|
|
"id": 1,
|
|
"external_id": "12345678-1234-1234-1234-123456789012",
|
|
"name": "test-project",
|
|
"path": "/test-project",
|
|
"is_default": False,
|
|
},
|
|
}
|
|
|
|
calls: list[dict] = []
|
|
|
|
async def fake_create_project(self, project_data):
|
|
calls.append(project_data)
|
|
return ProjectStatusResponse.model_validate(_response_data)
|
|
|
|
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
|
|
monkeypatch.setattr(ProjectClient, "create_project", fake_create_project)
|
|
|
|
return calls
|
|
|
|
|
|
def test_project_add_with_local_path_saves_to_config(
|
|
runner, mock_config, mock_api_client, tmp_path
|
|
):
|
|
"""Test that bm project add --local-path saves sync path to config."""
|
|
local_sync_dir = tmp_path / "sync" / "test-project"
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"project",
|
|
"add",
|
|
"test-project",
|
|
"--cloud",
|
|
"--local-path",
|
|
str(local_sync_dir),
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, f"Exit code: {result.exit_code}, Stdout: {result.stdout}"
|
|
assert "Project 'test-project' added successfully" in result.stdout
|
|
assert "Local sync path configured" in result.stdout
|
|
# Check path is present (may be line-wrapped in output)
|
|
assert "test-project" in result.stdout
|
|
assert "sync" in result.stdout
|
|
|
|
# Verify config was updated — sync path stored on the project entry
|
|
config_data = json.loads(mock_config.read_text())
|
|
assert "test-project" in config_data["projects"]
|
|
entry = config_data["projects"]["test-project"]
|
|
# Use as_posix() for cross-platform compatibility (Windows uses backslashes)
|
|
assert entry["local_sync_path"] == local_sync_dir.as_posix()
|
|
assert entry.get("last_sync") is None
|
|
assert entry.get("bisync_initialized", False) is False
|
|
|
|
# Verify local directory was created
|
|
assert local_sync_dir.exists()
|
|
assert local_sync_dir.is_dir()
|
|
|
|
|
|
def test_project_add_without_local_path_no_config_entry(runner, mock_config, mock_api_client):
|
|
"""Test that bm project add without --local-path doesn't save to config."""
|
|
result = runner.invoke(
|
|
app,
|
|
["project", "add", "test-project", "--cloud"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Project 'test-project' added successfully" in result.stdout
|
|
assert "Local sync path configured" not in result.stdout
|
|
|
|
# Verify config was NOT updated with cloud sync path
|
|
config_data = json.loads(mock_config.read_text())
|
|
# Project may or may not be in config, but if it is, local_sync_path should be null
|
|
entry = config_data.get("projects", {}).get("test-project")
|
|
if entry:
|
|
assert entry.get("local_sync_path") is None
|
|
|
|
|
|
def test_project_add_local_path_expands_tilde(runner, mock_config, mock_api_client):
|
|
"""Test that --local-path ~/path expands to absolute path."""
|
|
result = runner.invoke(
|
|
app,
|
|
["project", "add", "test-project", "--cloud", "--local-path", "~/test-sync"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
# Verify config has expanded path
|
|
config_data = json.loads(mock_config.read_text())
|
|
local_path = config_data["projects"]["test-project"]["local_sync_path"]
|
|
# Path should be absolute (starts with / on Unix or drive letter on Windows)
|
|
assert Path(local_path).is_absolute()
|
|
assert "~" not in local_path
|
|
assert local_path.endswith("/test-sync")
|
|
|
|
|
|
def test_project_add_local_path_creates_nested_directories(
|
|
runner, mock_config, mock_api_client, tmp_path
|
|
):
|
|
"""Test that --local-path creates nested directories."""
|
|
nested_path = tmp_path / "a" / "b" / "c" / "test-project"
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["project", "add", "test-project", "--cloud", "--local-path", str(nested_path)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert nested_path.exists()
|
|
assert nested_path.is_dir()
|