Files
basicmachines-co-basic-memory/tests/cli/test_project_set_cloud_local.py
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

266 lines
10 KiB
Python

"""Tests for bm project set-cloud and bm project set-local commands."""
import json
import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import app
# Importing the commands module registers the project subcommands with the app
import basic_memory.cli.commands.project # noqa: F401
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_config(tmp_path, monkeypatch):
"""Create a mock config with projects for testing set-cloud/set-local."""
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": {
"main": {"path": str(tmp_path / "main")},
"research": {"path": str(tmp_path / "research")},
},
"default_project": "main",
"cloud_api_key": "bmc_test_key_123",
}
config_file.write_text(json.dumps(config_data, indent=2))
monkeypatch.setenv("HOME", str(tmp_path))
yield config_file
class TestSetCloud:
"""Tests for bm project set-cloud command."""
def test_set_cloud_success(self, runner, mock_config):
"""Test setting a project to cloud mode."""
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0
assert "cloud mode" in result.stdout.lower()
# Verify config was updated
config_data = json.loads(mock_config.read_text())
assert config_data["projects"]["research"]["mode"] == "cloud"
def test_set_cloud_nonexistent_project(self, runner, mock_config):
"""Test set-cloud with a project that doesn't exist in config."""
result = runner.invoke(app, ["project", "set-cloud", "nonexistent"])
assert result.exit_code == 1
assert "not found" in result.stdout.lower()
def test_set_cloud_no_credentials(self, runner, tmp_path, monkeypatch):
"""Test set-cloud when neither API key nor OAuth session is available."""
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 without cloud_api_key
config_data = {
"env": "dev",
"projects": {"research": {"path": str(tmp_path / "research")}},
"default_project": "research",
}
config_file.write_text(json.dumps(config_data, indent=2))
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 1
assert "no cloud credentials" in result.stdout.lower()
def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
"""Test set-cloud succeeds with OAuth token but no API key."""
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 without cloud_api_key but with a project
config_data = {
"env": "dev",
"projects": {"research": {"path": str(tmp_path / "research")}},
"default_project": "research",
}
config_file.write_text(json.dumps(config_data, indent=2))
monkeypatch.setenv("HOME", str(tmp_path))
# Write OAuth token file so CLIAuth.load_tokens() returns something
token_file = config_dir / "basic-memory-cloud.json"
token_data = {
"access_token": "oauth-token-789",
"refresh_token": None,
"expires_at": 9999999999,
"token_type": "Bearer",
}
token_file.write_text(json.dumps(token_data, indent=2))
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0
assert "cloud mode" in result.stdout.lower()
# Verify config was updated
config_data = json.loads(config_file.read_text())
assert config_data["projects"]["research"]["mode"] == "cloud"
class TestSetLocal:
"""Tests for bm project set-local command."""
def test_set_local_success(self, runner, mock_config):
"""Test reverting a project to local mode."""
# First set to cloud
runner.invoke(app, ["project", "set-cloud", "research"])
config_data = json.loads(mock_config.read_text())
assert config_data["projects"]["research"]["mode"] == "cloud"
# Now set back to local
result = runner.invoke(app, ["project", "set-local", "research"])
assert result.exit_code == 0
assert "local mode" in result.stdout.lower()
# Verify config was updated — mode reset to local
config_data = json.loads(mock_config.read_text())
assert config_data["projects"]["research"]["mode"] == "local"
def test_set_local_nonexistent_project(self, runner, mock_config):
"""Test set-local with a project that doesn't exist in config."""
result = runner.invoke(app, ["project", "set-local", "nonexistent"])
assert result.exit_code == 1
assert "not found" in result.stdout.lower()
def test_set_local_already_local(self, runner, mock_config):
"""Test set-local on a project that's already local (no-op, should succeed)."""
result = runner.invoke(app, ["project", "set-local", "main"])
assert result.exit_code == 0
assert "local mode" in result.stdout.lower()
def test_set_local_clears_workspace_id(self, runner, mock_config):
"""Test that set-local clears workspace_id from the project entry."""
from basic_memory import config as config_module
# Manually set workspace_id on the project
config_module._CONFIG_CACHE = None
config_data = json.loads(mock_config.read_text())
config_data["projects"]["research"]["mode"] = "cloud"
config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111"
mock_config.write_text(json.dumps(config_data, indent=2))
config_module._CONFIG_CACHE = None
# Set back to local
result = runner.invoke(app, ["project", "set-local", "research"])
assert result.exit_code == 0
# Verify workspace_id was cleared
config_module._CONFIG_CACHE = None
updated_data = json.loads(mock_config.read_text())
assert updated_data["projects"]["research"]["workspace_id"] is None
assert updated_data["projects"]["research"]["mode"] == "local"
class TestSetCloudWithWorkspace:
"""Tests for 'bm project set-cloud --workspace' option."""
def test_set_cloud_with_workspace_stores_workspace_id(self, runner, mock_config, monkeypatch):
"""Test that --workspace resolves to tenant_id and stores it."""
from basic_memory import config as config_module
from basic_memory.schemas.cloud import WorkspaceInfo
config_module._CONFIG_CACHE = None
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
name="Personal",
role="owner",
),
]
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
result = runner.invoke(app, ["project", "set-cloud", "research", "--workspace", "Personal"])
assert result.exit_code == 0
assert "cloud mode" in result.stdout.lower()
assert "11111111-1111-1111-1111-111111111111" in result.stdout
# Verify workspace_id was persisted
config_module._CONFIG_CACHE = None
updated_data = json.loads(mock_config.read_text())
assert (
updated_data["projects"]["research"]["workspace_id"]
== "11111111-1111-1111-1111-111111111111"
)
def test_set_cloud_with_workspace_not_found(self, runner, mock_config, monkeypatch):
"""Test --workspace with unknown workspace name."""
from basic_memory import config as config_module
from basic_memory.schemas.cloud import WorkspaceInfo
config_module._CONFIG_CACHE = None
async def fake_get_available_workspaces():
return [
WorkspaceInfo(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
name="Personal",
role="owner",
),
]
monkeypatch.setattr(
"basic_memory.mcp.project_context.get_available_workspaces",
fake_get_available_workspaces,
)
result = runner.invoke(
app, ["project", "set-cloud", "research", "--workspace", "Nonexistent"]
)
assert result.exit_code == 1
assert "not found" in result.stdout.lower()
def test_set_cloud_uses_default_workspace_when_no_flag(self, runner, mock_config, monkeypatch):
"""Test that set-cloud uses default_workspace when --workspace is not passed."""
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
# Set default_workspace in config
config_data = json.loads(mock_config.read_text())
config_data["default_workspace"] = "global-default-tenant-id"
mock_config.write_text(json.dumps(config_data, indent=2))
config_module._CONFIG_CACHE = None
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0
# Verify workspace_id was set from default
config_module._CONFIG_CACHE = None
updated_data = json.loads(mock_config.read_text())
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"