mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
c4956cac16
Signed-off-by: phernandez <paul@basicmachines.co>
386 lines
15 KiB
Python
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"
|