Files
basicmachines-co-basic-memory/tests/cli/test_project_add_with_local_path.py
T
phernandez 2e5813d31e feat: CLI refactoring + workspace-aware cloud project listing
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>
2026-02-21 20:04:03 -06:00

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()