mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
f3e46d7984
Signed-off-by: phernandez <paul@basicmachines.co>
361 lines
11 KiB
Python
361 lines
11 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
|
|
|
|
|
|
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 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_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": {},
|
|
"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."""
|
|
seen_workspaces: list[str | None] = []
|
|
|
|
@asynccontextmanager
|
|
async def fake_get_client(*, workspace=None):
|
|
seen_workspaces.append(workspace)
|
|
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": calls, "workspaces": seen_workspaces}
|
|
|
|
|
|
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["mode"] == "cloud"
|
|
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()
|
|
|
|
|
|
def test_project_add_cloud_visibility_passes_payload(runner, mock_config, mock_api_client):
|
|
"""Cloud project creation should forward visibility to the API payload."""
|
|
result = runner.invoke(
|
|
app,
|
|
["project", "add", "test-project", "--cloud", "--visibility", "shared"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_api_client["workspaces"] == [None]
|
|
assert mock_api_client["calls"] == [
|
|
{
|
|
"name": "test-project",
|
|
"path": "test-project",
|
|
"local_sync_path": None,
|
|
"set_default": False,
|
|
"visibility": "shared",
|
|
}
|
|
]
|
|
|
|
|
|
def test_project_add_cloud_workspace_resolves_and_persists(
|
|
runner, mock_config, mock_api_client, monkeypatch, tmp_path
|
|
):
|
|
"""Cloud project add should resolve workspace names to tenant IDs."""
|
|
|
|
local_sync_dir = tmp_path / "sync" / "team-notes"
|
|
|
|
async def fake_get_available_workspaces():
|
|
return [
|
|
_workspace(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="organization",
|
|
slug="basic-memory",
|
|
name="Basic Memory",
|
|
role="owner",
|
|
is_default=True,
|
|
),
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fake_get_available_workspaces,
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"project",
|
|
"add",
|
|
"team-notes",
|
|
"--cloud",
|
|
"--workspace",
|
|
"Basic Memory",
|
|
"--local-path",
|
|
str(local_sync_dir),
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_api_client["workspaces"] == ["11111111-1111-1111-1111-111111111111"]
|
|
assert mock_api_client["calls"] == [
|
|
{
|
|
"name": "team-notes",
|
|
"path": "team-notes",
|
|
"local_sync_path": local_sync_dir.as_posix(),
|
|
"set_default": False,
|
|
"visibility": "workspace",
|
|
}
|
|
]
|
|
|
|
config_data = json.loads(mock_config.read_text())
|
|
entry = config_data["projects"]["team-notes"]
|
|
assert entry["mode"] == "cloud"
|
|
assert entry["workspace_id"] == "11111111-1111-1111-1111-111111111111"
|
|
|
|
|
|
def test_project_add_cloud_workspace_persists_without_local_path(
|
|
runner, mock_config, mock_api_client, monkeypatch
|
|
):
|
|
"""Cloud project add should persist workspace routing even without local sync."""
|
|
|
|
async def fake_get_available_workspaces():
|
|
return [
|
|
_workspace(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="organization",
|
|
slug="basic-memory",
|
|
name="Basic Memory",
|
|
role="owner",
|
|
is_default=True,
|
|
),
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fake_get_available_workspaces,
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"project",
|
|
"add",
|
|
"team-notes",
|
|
"--cloud",
|
|
"--workspace",
|
|
"Basic Memory",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_api_client["workspaces"] == ["11111111-1111-1111-1111-111111111111"]
|
|
assert mock_api_client["calls"] == [
|
|
{
|
|
"name": "team-notes",
|
|
"path": "team-notes",
|
|
"local_sync_path": None,
|
|
"set_default": False,
|
|
"visibility": "workspace",
|
|
}
|
|
]
|
|
|
|
config_data = json.loads(mock_config.read_text())
|
|
entry = config_data["projects"]["team-notes"]
|
|
assert entry["path"] == ""
|
|
assert entry["mode"] == "cloud"
|
|
assert entry["workspace_id"] == "11111111-1111-1111-1111-111111111111"
|
|
assert entry["local_sync_path"] is None
|
|
|
|
|
|
def test_project_add_visibility_requires_cloud_mode(runner, mock_config, tmp_path):
|
|
"""Visibility is a cloud-only option."""
|
|
project_path = tmp_path / "local-project"
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"project",
|
|
"add",
|
|
"local-project",
|
|
str(project_path),
|
|
"--visibility",
|
|
"shared",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "--visibility is only supported in cloud mode" in result.stdout
|
|
|
|
|
|
def test_project_add_rejects_invalid_visibility(runner, mock_config):
|
|
"""Invalid visibility values should fail fast before the API call."""
|
|
result = runner.invoke(
|
|
app,
|
|
["project", "add", "test-project", "--cloud", "--visibility", "team-only"],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "Invalid visibility" in result.stdout
|