mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
a4e0422926
Signed-off-by: phernandez <paul@basicmachines.co>
759 lines
28 KiB
Python
759 lines
28 KiB
Python
"""Tests for project context utilities (no standard-library mock usage).
|
|
|
|
These functions are config/env driven, so we use the real ConfigManager-backed
|
|
test config file and pytest monkeypatch for environment variables.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
|
|
class _ContextState:
|
|
"""Minimal FastMCP context-state stub for unit tests."""
|
|
|
|
def __init__(self):
|
|
self._state: dict[str, object] = {}
|
|
|
|
async def get_state(self, key: str):
|
|
return self._state.get(key)
|
|
|
|
async def set_state(self, key: str, value: object, **kwargs) -> None:
|
|
self._state[key] = value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_when_no_default_and_no_project(config_manager, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
cfg.default_project = None
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
|
|
|
|
# Prevent API fallback from returning a project via stale dependency overrides
|
|
async def _no_api_fallback():
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context._resolve_default_project_from_api",
|
|
_no_api_fallback,
|
|
)
|
|
assert await resolve_project_parameter(project=None, allow_discovery=False) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_discovery_when_enabled(config_manager, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
cfg.default_project = None
|
|
config_manager.save_config(cfg)
|
|
|
|
# Prevent API fallback from returning a project via stale dependency overrides
|
|
async def _no_api_fallback():
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context._resolve_default_project_from_api",
|
|
_no_api_fallback,
|
|
)
|
|
assert await resolve_project_parameter(project=None, allow_discovery=True) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_project_when_specified(config_manager):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
config_manager.save_config(cfg)
|
|
|
|
assert await resolve_project_parameter(project="my-project") == "my-project"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_env_var_priority(config_manager, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "env-project")
|
|
assert await resolve_project_parameter(project="explicit-project") == "env-project"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_explicit_project_when_no_env(config_manager, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
|
|
assert await resolve_project_parameter(project="explicit-project") == "explicit-project"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canonicalizes_case_insensitive_project_reference(
|
|
config_manager, config_home, monkeypatch
|
|
):
|
|
from basic_memory.config import ProjectEntry
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
project_name = "Personal-Project"
|
|
project_path = config_home / "personal-project"
|
|
project_path.mkdir(parents=True, exist_ok=True)
|
|
cfg.projects[project_name] = ProjectEntry(path=str(project_path))
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
|
|
|
|
assert await resolve_project_parameter(project="personal-project") == project_name
|
|
assert await resolve_project_parameter(project="PERSONAL-PROJECT") == project_name
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_default_project(config_manager, config_home, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
from basic_memory.config import ProjectEntry
|
|
|
|
cfg = config_manager.load_config()
|
|
(config_home / "default-project").mkdir(parents=True, exist_ok=True)
|
|
cfg.projects["default-project"] = ProjectEntry(path=str(config_home / "default-project"))
|
|
cfg.default_project = "default-project"
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
|
|
assert await resolve_project_parameter(project=None) == "default-project"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_when_no_default(config_manager, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
cfg = config_manager.load_config()
|
|
cfg.default_project = None
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
|
|
|
|
# Prevent API fallback from returning a project via stale dependency overrides
|
|
async def _no_api_fallback():
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context._resolve_default_project_from_api",
|
|
_no_api_fallback,
|
|
)
|
|
assert await resolve_project_parameter(project=None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_env_constraint_overrides_default(config_manager, config_home, monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
from basic_memory.config import ProjectEntry
|
|
|
|
cfg = config_manager.load_config()
|
|
(config_home / "default-project").mkdir(parents=True, exist_ok=True)
|
|
cfg.projects["default-project"] = ProjectEntry(path=str(config_home / "default-project"))
|
|
cfg.default_project = "default-project"
|
|
config_manager.save_config(cfg)
|
|
|
|
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "env-project")
|
|
assert await resolve_project_parameter(project=None) == "env-project"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_auto_selects_single_and_caches(monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
|
from basic_memory.schemas.cloud import WorkspaceInfo
|
|
|
|
context = _ContextState()
|
|
only_workspace = WorkspaceInfo(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="personal",
|
|
name="Personal",
|
|
role="owner",
|
|
)
|
|
|
|
async def fake_get_available_workspaces(context=None):
|
|
return [only_workspace]
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fake_get_available_workspaces,
|
|
)
|
|
|
|
resolved = await resolve_workspace_parameter(context=context)
|
|
assert resolved.tenant_id == only_workspace.tenant_id
|
|
assert await context.get_state("active_workspace") == only_workspace.model_dump()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_requires_user_choice_when_multiple(monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
|
from basic_memory.schemas.cloud import WorkspaceInfo
|
|
|
|
workspaces = [
|
|
WorkspaceInfo(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="personal",
|
|
name="Personal",
|
|
role="owner",
|
|
),
|
|
WorkspaceInfo(
|
|
tenant_id="22222222-2222-2222-2222-222222222222",
|
|
workspace_type="organization",
|
|
name="Team",
|
|
role="editor",
|
|
),
|
|
]
|
|
|
|
async def fake_get_available_workspaces(context=None):
|
|
return workspaces
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fake_get_available_workspaces,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Multiple workspaces are available"):
|
|
await resolve_workspace_parameter(context=_ContextState())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_explicit_selection_by_tenant_id_or_name(monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
|
from basic_memory.schemas.cloud import WorkspaceInfo
|
|
|
|
team_workspace = WorkspaceInfo(
|
|
tenant_id="22222222-2222-2222-2222-222222222222",
|
|
workspace_type="organization",
|
|
name="Team",
|
|
role="editor",
|
|
)
|
|
workspaces = [
|
|
WorkspaceInfo(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="personal",
|
|
name="Personal",
|
|
role="owner",
|
|
),
|
|
team_workspace,
|
|
]
|
|
|
|
async def fake_get_available_workspaces(context=None):
|
|
return workspaces
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fake_get_available_workspaces,
|
|
)
|
|
|
|
resolved_by_id = await resolve_workspace_parameter(workspace=team_workspace.tenant_id)
|
|
assert resolved_by_id.tenant_id == team_workspace.tenant_id
|
|
|
|
resolved_by_name = await resolve_workspace_parameter(workspace="team")
|
|
assert resolved_by_name.tenant_id == team_workspace.tenant_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_invalid_selection_lists_choices(monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
|
from basic_memory.schemas.cloud import WorkspaceInfo
|
|
|
|
workspaces = [
|
|
WorkspaceInfo(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="personal",
|
|
name="Personal",
|
|
role="owner",
|
|
)
|
|
]
|
|
|
|
async def fake_get_available_workspaces(context=None):
|
|
return workspaces
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fake_get_available_workspaces,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Workspace 'missing-workspace' was not found"):
|
|
await resolve_workspace_parameter(workspace="missing-workspace")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
|
|
from basic_memory.mcp.project_context import resolve_workspace_parameter
|
|
from basic_memory.schemas.cloud import WorkspaceInfo
|
|
|
|
cached_workspace = WorkspaceInfo(
|
|
tenant_id="11111111-1111-1111-1111-111111111111",
|
|
workspace_type="personal",
|
|
name="Personal",
|
|
role="owner",
|
|
)
|
|
context = _ContextState()
|
|
await context.set_state("active_workspace", cached_workspace.model_dump())
|
|
|
|
async def fail_if_called(context=None): # pragma: no cover
|
|
raise AssertionError("Workspace fetch should not run when cache is available")
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.get_available_workspaces",
|
|
fail_if_called,
|
|
)
|
|
|
|
resolved = await resolve_workspace_parameter(context=context)
|
|
assert resolved.tenant_id == cached_workspace.tenant_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_project_parameter_uses_cached_active_project_before_api_default_lookup(
|
|
config_manager, monkeypatch
|
|
):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
from basic_memory.schemas.project_info import ProjectItem
|
|
|
|
config = config_manager.load_config()
|
|
config.default_project = None
|
|
config_manager.save_config(config)
|
|
|
|
context = _ContextState()
|
|
cached_project = ProjectItem(
|
|
id=1,
|
|
external_id="11111111-1111-1111-1111-111111111111",
|
|
name="Cached Project",
|
|
path="/tmp/cached-project",
|
|
is_default=True,
|
|
)
|
|
await context.set_state("active_project", cached_project.model_dump())
|
|
|
|
async def fail_if_called(): # pragma: no cover
|
|
raise AssertionError("Default project API lookup should not run when project is cached")
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context._resolve_default_project_from_api",
|
|
fail_if_called,
|
|
)
|
|
|
|
resolved = await resolve_project_parameter(project=None, context=context)
|
|
assert resolved == cached_project.name
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_project_parameter_caches_api_default_project_name(
|
|
config_manager, monkeypatch
|
|
):
|
|
from basic_memory.mcp.project_context import resolve_project_parameter
|
|
|
|
config = config_manager.load_config()
|
|
config.default_project = None
|
|
config_manager.save_config(config)
|
|
|
|
context = _ContextState()
|
|
api_calls = {"count": 0}
|
|
|
|
async def fake_default_lookup():
|
|
api_calls["count"] += 1
|
|
return "cloud-default"
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context._resolve_default_project_from_api",
|
|
fake_default_lookup,
|
|
)
|
|
|
|
first = await resolve_project_parameter(project=None, context=context)
|
|
second = await resolve_project_parameter(project=None, context=context)
|
|
|
|
assert first == "cloud-default"
|
|
assert second == "cloud-default"
|
|
assert api_calls["count"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_project_uses_cached_project_before_resolution(monkeypatch):
|
|
from basic_memory.mcp.project_context import get_active_project
|
|
from basic_memory.schemas.project_info import ProjectItem
|
|
|
|
context = _ContextState()
|
|
cached_project = ProjectItem(
|
|
id=1,
|
|
external_id="11111111-1111-1111-1111-111111111111",
|
|
name="Cached Project",
|
|
path="/tmp/cached-project",
|
|
is_default=True,
|
|
)
|
|
await context.set_state("active_project", cached_project.model_dump())
|
|
|
|
async def fail_if_called(*args, **kwargs): # pragma: no cover
|
|
raise AssertionError("Project resolution should not run when cache matches")
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_project_parameter",
|
|
fail_if_called,
|
|
)
|
|
|
|
resolved = await get_active_project(client=None, context=context)
|
|
assert resolved == cached_project
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_project_uses_cached_project_for_explicit_permalink(monkeypatch):
|
|
from basic_memory.mcp.project_context import get_active_project
|
|
from basic_memory.schemas.project_info import ProjectItem
|
|
|
|
context = _ContextState()
|
|
cached_project = ProjectItem(
|
|
id=1,
|
|
external_id="11111111-1111-1111-1111-111111111111",
|
|
name="My Research",
|
|
path="/tmp/my-research",
|
|
is_default=False,
|
|
)
|
|
await context.set_state("active_project", cached_project.model_dump())
|
|
|
|
async def fail_if_called(*args, **kwargs): # pragma: no cover
|
|
raise AssertionError(
|
|
"Project resolution should not run when explicit project matches cache"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_project_parameter",
|
|
fail_if_called,
|
|
)
|
|
|
|
resolved = await get_active_project(client=None, project="my-research", context=context)
|
|
assert resolved == cached_project
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_project_and_path_uses_cached_project_for_memory_url_prefix(
|
|
config_manager, monkeypatch
|
|
):
|
|
from basic_memory.mcp.project_context import resolve_project_and_path
|
|
from basic_memory.schemas.project_info import ProjectItem
|
|
|
|
config = config_manager.load_config()
|
|
config.permalinks_include_project = False
|
|
config_manager.save_config(config)
|
|
|
|
context = _ContextState()
|
|
cached_project = ProjectItem(
|
|
id=1,
|
|
external_id="11111111-1111-1111-1111-111111111111",
|
|
name="My Research",
|
|
path="/tmp/my-research",
|
|
is_default=False,
|
|
)
|
|
await context.set_state("active_project", cached_project.model_dump())
|
|
|
|
async def fail_if_called(*args, **kwargs): # pragma: no cover
|
|
raise AssertionError("Project resolve API should not run when memory URL matches cache")
|
|
|
|
async def fake_resolve_project_parameter(project=None, **kwargs):
|
|
return cached_project.name if project else cached_project.name
|
|
|
|
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", fail_if_called)
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_project_parameter",
|
|
fake_resolve_project_parameter,
|
|
)
|
|
|
|
active_project, resolved_path, is_memory_url = await resolve_project_and_path(
|
|
client=None,
|
|
identifier="memory://my-research/notes/roadmap.md",
|
|
context=context,
|
|
)
|
|
|
|
assert active_project == cached_project
|
|
assert resolved_path == "notes/roadmap.md"
|
|
assert is_memory_url is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_project_client_rejects_workspace_for_local_project(config_manager):
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
from basic_memory.config import ProjectEntry
|
|
|
|
# Register "main" as a LOCAL project so get_project_mode returns LOCAL
|
|
config = config_manager.load_config()
|
|
(config_manager.config_dir.parent / "main").mkdir(parents=True, exist_ok=True)
|
|
config.projects["main"] = ProjectEntry(path=str(config_manager.config_dir.parent / "main"))
|
|
config_manager.save_config(config)
|
|
|
|
with pytest.raises(
|
|
ValueError, match="Workspace 'tenant-123' cannot be used with local project"
|
|
):
|
|
async with get_project_client(project="main", workspace="tenant-123"):
|
|
pass
|
|
|
|
|
|
class TestDetectProjectFromUrlPrefix:
|
|
"""Test detect_project_from_url_prefix for URL-based project detection."""
|
|
|
|
def test_detects_project_from_memory_url(self, config_manager):
|
|
from basic_memory.mcp.project_context import detect_project_from_url_prefix
|
|
|
|
config = config_manager.load_config()
|
|
# The config has "test-project" from the conftest fixture
|
|
result = detect_project_from_url_prefix("memory://test-project/some-note", config)
|
|
assert result == "test-project"
|
|
|
|
def test_detects_project_from_plain_path(self, config_manager):
|
|
from basic_memory.mcp.project_context import detect_project_from_url_prefix
|
|
|
|
config = config_manager.load_config()
|
|
result = detect_project_from_url_prefix("test-project/some-note", config)
|
|
assert result == "test-project"
|
|
|
|
def test_returns_none_for_unknown_prefix(self, config_manager):
|
|
from basic_memory.mcp.project_context import detect_project_from_url_prefix
|
|
|
|
config = config_manager.load_config()
|
|
result = detect_project_from_url_prefix("memory://unknown-project/note", config)
|
|
assert result is None
|
|
|
|
def test_returns_none_for_no_slash(self, config_manager):
|
|
from basic_memory.mcp.project_context import detect_project_from_url_prefix
|
|
|
|
config = config_manager.load_config()
|
|
result = detect_project_from_url_prefix("memory://single-segment", config)
|
|
assert result is None
|
|
|
|
def test_returns_none_for_wildcard_prefix(self, config_manager):
|
|
from basic_memory.mcp.project_context import detect_project_from_url_prefix
|
|
|
|
config = config_manager.load_config()
|
|
result = detect_project_from_url_prefix("memory://*/notes", config)
|
|
assert result is None
|
|
|
|
def test_matches_case_insensitive_via_permalink(self, config_manager):
|
|
from basic_memory.mcp.project_context import detect_project_from_url_prefix
|
|
from basic_memory.config import ProjectEntry
|
|
|
|
config = config_manager.load_config()
|
|
(config_manager.config_dir.parent / "My Research").mkdir(parents=True, exist_ok=True)
|
|
config.projects["My Research"] = ProjectEntry(
|
|
path=str(config_manager.config_dir.parent / "My Research")
|
|
)
|
|
config_manager.save_config(config)
|
|
|
|
result = detect_project_from_url_prefix("memory://my-research/notes", config)
|
|
assert result == "My Research"
|
|
|
|
|
|
class TestGetProjectClientRoutingOrder:
|
|
"""Test that get_project_client respects explicit routing before workspace resolution."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_flag_skips_workspace_resolution(self, config_manager, monkeypatch):
|
|
"""--local flag should never trigger workspace resolution, even for cloud projects."""
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
from basic_memory.config import ProjectEntry, ProjectMode
|
|
|
|
config = config_manager.load_config()
|
|
config.projects["cloud-proj"] = ProjectEntry(
|
|
path=str(config_manager.config_dir.parent / "cloud-proj"),
|
|
mode=ProjectMode.CLOUD,
|
|
)
|
|
config_manager.save_config(config)
|
|
|
|
# Set explicit local routing
|
|
monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true")
|
|
monkeypatch.setenv("BASIC_MEMORY_FORCE_LOCAL", "true")
|
|
monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False)
|
|
|
|
# Should not raise "Multiple workspaces" — it should skip workspace entirely
|
|
# It will fail at project validation (no API running), which proves routing worked
|
|
with pytest.raises(Exception) as exc_info:
|
|
async with get_project_client(project="cloud-proj"):
|
|
pass
|
|
|
|
# The error should NOT be about workspaces
|
|
assert "workspace" not in str(exc_info.value).lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_project_uses_per_project_workspace_id(self, config_manager, monkeypatch):
|
|
"""Cloud project with workspace_id in config should use it without network lookup."""
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
from basic_memory.config import ProjectEntry, ProjectMode
|
|
|
|
config = config_manager.load_config()
|
|
config.projects["cloud-proj"] = ProjectEntry(
|
|
path=str(config_manager.config_dir.parent / "cloud-proj"),
|
|
mode=ProjectMode.CLOUD,
|
|
workspace_id="per-project-tenant-id",
|
|
)
|
|
config.cloud_api_key = "bmc_test123"
|
|
config_manager.save_config(config)
|
|
|
|
# Patch resolve_workspace_parameter to fail if called — it should be skipped
|
|
async def fail_if_called(**kwargs): # pragma: no cover
|
|
raise AssertionError(
|
|
"resolve_workspace_parameter should not be called when workspace_id is set"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_workspace_parameter",
|
|
fail_if_called,
|
|
)
|
|
|
|
# Will fail at cloud client creation (no real cloud), but proves workspace
|
|
# resolution was skipped
|
|
with pytest.raises(Exception) as exc_info:
|
|
async with get_project_client(project="cloud-proj"):
|
|
pass
|
|
|
|
# Should not be a workspace resolution error
|
|
error_msg = str(exc_info.value).lower()
|
|
assert "resolve_workspace_parameter should not be called" not in error_msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_project_uses_default_workspace(self, config_manager, monkeypatch):
|
|
"""Cloud project without workspace_id should fall back to default_workspace."""
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
from basic_memory.config import ProjectEntry, ProjectMode
|
|
|
|
config = config_manager.load_config()
|
|
config.projects["cloud-proj"] = ProjectEntry(
|
|
path=str(config_manager.config_dir.parent / "cloud-proj"),
|
|
mode=ProjectMode.CLOUD,
|
|
)
|
|
config.default_workspace = "global-default-tenant-id"
|
|
config.cloud_api_key = "bmc_test123"
|
|
config_manager.save_config(config)
|
|
|
|
# Patch resolve_workspace_parameter to fail if called — it should be skipped
|
|
async def fail_if_called(**kwargs): # pragma: no cover
|
|
raise AssertionError(
|
|
"resolve_workspace_parameter should not be called when default_workspace is set"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_workspace_parameter",
|
|
fail_if_called,
|
|
)
|
|
|
|
# Will fail at cloud client creation, but proves workspace resolution was skipped
|
|
with pytest.raises(Exception) as exc_info:
|
|
async with get_project_client(project="cloud-proj"):
|
|
pass
|
|
|
|
error_msg = str(exc_info.value).lower()
|
|
assert "resolve_workspace_parameter should not be called" not in error_msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_only_project_routes_to_cloud(self, config_manager, monkeypatch):
|
|
"""Project NOT in local config should route to cloud (not default to LOCAL).
|
|
|
|
Cloud-only projects aren't registered in local config. The routing logic
|
|
should detect this and use CLOUD mode, falling back to default_workspace.
|
|
"""
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
|
|
config = config_manager.load_config()
|
|
# Do NOT add "cloud-only-proj" to config.projects — it's cloud-only
|
|
config.default_workspace = "global-default-tenant-id"
|
|
config.cloud_api_key = "bmc_test123"
|
|
config_manager.save_config(config)
|
|
|
|
# Patch resolve_workspace_parameter to fail if called — it should be skipped
|
|
# because default_workspace is set (priority 3)
|
|
async def fail_if_called(**kwargs): # pragma: no cover
|
|
raise AssertionError(
|
|
"resolve_workspace_parameter should not be called when default_workspace is set"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_workspace_parameter",
|
|
fail_if_called,
|
|
)
|
|
|
|
# Will fail at cloud client creation (no real cloud), but proves cloud routing
|
|
# was selected instead of local routing
|
|
with pytest.raises(Exception) as exc_info:
|
|
async with get_project_client(project="cloud-only-proj"):
|
|
pass
|
|
|
|
# The error should NOT be about workspace resolution or local routing
|
|
error_msg = str(exc_info.value).lower()
|
|
assert "resolve_workspace_parameter should not be called" not in error_msg
|
|
# Should not get a local ASGI routing error
|
|
assert "no project found" not in error_msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_factory_mode_skips_workspace_resolution(self, config_manager, monkeypatch):
|
|
"""When a client factory is set (in-process cloud server), skip workspace resolution.
|
|
|
|
The cloud MCP server calls set_client_factory() so that get_client() routes
|
|
requests through TenantASGITransport. In this mode, workspace and tenant context
|
|
are already resolved by the transport layer. Attempting cloud workspace resolution
|
|
would call the production control-plane API and fail with 401.
|
|
"""
|
|
from contextlib import asynccontextmanager
|
|
|
|
from basic_memory.mcp import async_client
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
from basic_memory.config import ProjectEntry, ProjectMode
|
|
|
|
config = config_manager.load_config()
|
|
config.projects["cloud-proj"] = ProjectEntry(
|
|
path=str(config_manager.config_dir.parent / "cloud-proj"),
|
|
mode=ProjectMode.CLOUD,
|
|
)
|
|
config_manager.save_config(config)
|
|
|
|
# Set up a factory (simulates what cloud MCP server does)
|
|
@asynccontextmanager
|
|
async def fake_factory():
|
|
from httpx import ASGITransport, AsyncClient
|
|
from basic_memory.api.app import app as fastapi_app
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=fastapi_app),
|
|
base_url="http://test",
|
|
) as client:
|
|
yield client
|
|
|
|
original_factory = async_client._client_factory
|
|
async_client.set_client_factory(fake_factory)
|
|
|
|
# Patch workspace resolution to fail if called — factory mode should skip it
|
|
async def fail_if_called(**kwargs): # pragma: no cover
|
|
raise AssertionError("resolve_workspace_parameter must not be called in factory mode")
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.project_context.resolve_workspace_parameter",
|
|
fail_if_called,
|
|
)
|
|
|
|
# Patch get_cloud_control_plane_client to fail if called
|
|
@asynccontextmanager
|
|
async def fail_control_plane(): # pragma: no cover
|
|
raise AssertionError(
|
|
"get_cloud_control_plane_client must not be called in factory mode"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"basic_memory.mcp.async_client.get_cloud_control_plane_client",
|
|
fail_control_plane,
|
|
)
|
|
|
|
try:
|
|
# Will fail at project validation (no real project in DB), but proves
|
|
# workspace resolution and control-plane calls were skipped
|
|
with pytest.raises(Exception) as exc_info:
|
|
async with get_project_client(project="cloud-proj"):
|
|
pass
|
|
|
|
error_msg = str(exc_info.value).lower()
|
|
assert "resolve_workspace_parameter must not be called" not in error_msg
|
|
assert "get_cloud_control_plane_client must not be called" not in error_msg
|
|
finally:
|
|
# Restore original factory to avoid polluting other tests
|
|
async_client._client_factory = original_factory
|