Files
basicmachines-co-basic-memory/tests/cli/test_project_set_cloud_local.py
2026-04-29 11:50:14 -05:00

386 lines
15 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
def _workspace(
*,
tenant_id: str,
workspace_type: str,
name: str,
role: str,
slug: str | None = None,
is_default: bool = False,
):
from basic_memory.schemas.cloud import WorkspaceInfo
return WorkspaceInfo(
tenant_id=tenant_id,
workspace_type=workspace_type,
slug=slug or name.casefold().replace(" ", "-"),
name=name,
role=role,
is_default=is_default,
)
@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_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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, tmp_path):
"""Test reverting a project to local mode."""
# First set to cloud (clears the local path as part of the cutover)
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 — must supply a path since set-cloud blanked it
new_path = tmp_path / "research"
result = runner.invoke(
app, ["project", "set-local", "research", "--local-path", str(new_path)]
)
assert result.exit_code == 0
assert "local mode" in result.stdout.lower()
# Verify config was updated — mode reset to local, path restored.
# set-local normalizes the path via Path.as_posix(), matching the
# convention used by `bm project add`. On Windows that means
# backslashes are converted to forward slashes.
config_data = json.loads(mock_config.read_text())
assert config_data["projects"]["research"]["mode"] == "local"
assert config_data["projects"]["research"]["path"] == new_path.as_posix()
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 (reuses existing path)."""
result = runner.invoke(app, ["project", "set-local", "main"])
assert result.exit_code == 0
assert "local mode" in result.stdout.lower()
def test_set_local_requires_path_after_set_cloud(self, runner, mock_config):
"""Regression for #680: after set-cloud blanks the path, set-local must
refuse to silently default — the user has to specify where the project
lives now."""
runner.invoke(app, ["project", "set-cloud", "research"])
# No --local-path; config no longer has a path either.
result = runner.invoke(app, ["project", "set-local", "research"])
assert result.exit_code == 1
assert "--local-path" in result.stdout
def test_set_local_clears_workspace_id(self, runner, mock_config, tmp_path):
"""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_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
# Set back to local — supply --local-path; existing config path is preserved
# in this test setup, but new behavior recommends explicit path passing.
new_path = tmp_path / "research"
result = runner.invoke(
app, ["project", "set-local", "research", "--local-path", str(new_path)]
)
assert result.exit_code == 0
# Verify workspace_id was cleared
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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"
def test_set_local_update_path_when_row_exists(self, runner, mock_config, tmp_path):
"""Re-running set-local with a different --local-path must update the
existing DB row (covers the repo.update_path() branch in
_attach_local_project_row)."""
path_a = tmp_path / "research_v1"
path_b = tmp_path / "research_v2"
# First call seeds the DB row at path_a.
result_a = runner.invoke(
app, ["project", "set-local", "research", "--local-path", str(path_a)]
)
assert result_a.exit_code == 0
# Second call must update the existing row's path to path_b.
result_b = runner.invoke(
app, ["project", "set-local", "research", "--local-path", str(path_b)]
)
assert result_b.exit_code == 0
updated = json.loads(mock_config.read_text())
assert updated["projects"]["research"]["path"] == path_b.as_posix()
class TestSetCloudCutover:
"""Regression tests for #680 — set-cloud as a one-way cutover."""
def test_set_cloud_clears_path(self, runner, mock_config):
"""After set-cloud, config.projects[name].path must be blanked so the
merged project list reports source: cloud (not local+cloud)."""
runner.invoke(app, ["project", "set-cloud", "research"])
updated = json.loads(mock_config.read_text())
assert updated["projects"]["research"]["mode"] == "cloud"
assert updated["projects"]["research"]["path"] == ""
def test_set_cloud_removes_existing_db_row(self, runner, mock_config, tmp_path):
"""When set-cloud runs against a project with a DB row, the row must
be removed and the user-facing message must mention the cleanup
(covers the repo.delete() → return True branch in
_detach_local_project_row)."""
# Seed a DB row first by going through set-local.
research_path = tmp_path / "research"
seed = runner.invoke(
app, ["project", "set-local", "research", "--local-path", str(research_path)]
)
assert seed.exit_code == 0
# Now flip to cloud — _detach_local_project_row should find and drop the row.
result = runner.invoke(app, ["project", "set-cloud", "research"])
assert result.exit_code == 0
assert "local index entry removed" in result.stdout.lower()
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
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
async def fake_get_available_workspaces():
return [
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
]
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
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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
config_module._CONFIG_CACHE = None
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
async def fake_get_available_workspaces():
return [
_workspace(
tenant_id="11111111-1111-1111-1111-111111111111",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
),
]
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
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = 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
config_module._CONFIG_MTIME = None
config_module._CONFIG_SIZE = None
updated_data = json.loads(mock_config.read_text())
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"