fix: double-default display in project list + stale test updates

Use config.default_project as single source of truth for the Default
column in `bm project list`, removing checks against local DB and cloud
API is_default fields that could independently mark multiple projects.

Also updates tests that were out of date after get_project_mode changed
to default unknown projects to CLOUD:
- test_get_client_local_project_uses_asgi_transport: register "main" as LOCAL
- test_run_filters_cloud_projects_each_cycle: register local project in config
- test_new_project_addition_scenario: register projects as LOCAL in config
- test_get_project_mode_defaults_to_cloud: assert new CLOUD default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-22 00:33:25 -06:00
parent 56cefbaafd
commit 6ff39076a0
9 changed files with 121 additions and 201 deletions
+21 -9
View File
@@ -161,13 +161,7 @@ def list_projects(
else:
cli_route = ProjectMode.LOCAL.value
is_default = ""
if config.default_project == project_name:
is_default = "[X]"
if local_project is not None and local_project.is_default:
is_default = "[X]"
if cloud_project is not None and cloud_project.is_default:
is_default = "[X]"
is_default = "[X]" if config.default_project == project_name else ""
has_sync = "[X]" if entry and entry.local_sync_path else ""
mcp_stdio_target = "local" if local_project is not None else "n/a"
@@ -326,7 +320,16 @@ def remove_project(
raise typer.Exit(1)
async def _remove_project():
async with get_client() as client:
# Resolve workspace so cloud-only projects auto-route without --cloud
config = ConfigManager().config
entry = config.projects.get(name)
ws = None
if entry and entry.workspace_id:
ws = entry.workspace_id
elif config.default_workspace:
ws = config.default_workspace
async with get_client(project_name=name, workspace=ws) as client:
project_client = ProjectClient(client)
# Convert name to permalink for efficient resolution
project_permalink = generate_permalink(name)
@@ -405,7 +408,16 @@ def set_default_project(
"""
async def _set_default():
async with get_client() as client:
# Resolve workspace so cloud-only projects auto-route without flags
config = ConfigManager().config
entry = config.projects.get(name)
ws = None
if entry and entry.workspace_id:
ws = entry.workspace_id
elif config.default_workspace:
ws = config.default_workspace
async with get_client(project_name=name, workspace=ws) as client:
project_client = ProjectClient(client)
# Convert name to permalink for efficient resolution
project_permalink = generate_permalink(name)
+9 -47
View File
@@ -14,7 +14,6 @@ from loguru import logger
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
@@ -36,16 +35,6 @@ VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"]
# --- Shared helpers ---
def _resolve_project(config_manager: ConfigManager, project: Optional[str]) -> Optional[str]:
"""Resolve project name from CLI arg or config default."""
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
raise ValueError(f"No project found named: {project}")
return project_name
return config_manager.default_project
def _print_json(result: Any) -> None:
"""Print a result as formatted JSON."""
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
@@ -108,9 +97,6 @@ def write_note(
typer.echo("Empty content provided. Please provide non-empty content.", err=True)
raise typer.Exit(1)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
assert content is not None
with force_routing(local=local, cloud=cloud):
@@ -119,7 +105,7 @@ def write_note(
title=title,
content=content,
directory=folder,
project=project_name,
project=project,
workspace=workspace,
tags=tags,
output_format="json",
@@ -168,14 +154,11 @@ def read_note(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_read_note(
identifier=identifier,
project=project_name,
project=project,
workspace=workspace,
page=page,
page_size=page_size,
@@ -237,16 +220,13 @@ def edit_note(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_edit_note(
identifier=identifier,
operation=operation,
content=content,
project=project_name,
project=project,
workspace=workspace,
section=section,
find_text=find_text,
@@ -304,14 +284,11 @@ def build_context(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_build_context(
url=url,
project=project_name,
project=project,
workspace=workspace,
depth=depth,
timeframe=timeframe,
@@ -365,9 +342,6 @@ def recent_activity(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_recent_activity(
@@ -376,7 +350,7 @@ def recent_activity(
timeframe=timeframe if timeframe is not None else "7d",
page=page,
page_size=page_size,
project=project_name,
project=project,
workspace=workspace,
output_format="json",
)
@@ -460,9 +434,6 @@ def search_notes(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
mode_flags = [permalink, title, vector, hybrid]
if sum(1 for enabled in mode_flags if enabled) > 1: # pragma: no cover
typer.echo(
@@ -515,7 +486,7 @@ def search_notes(
result = run_with_cleanup(
mcp_search(
query=query or "",
project=project_name,
project=project,
workspace=workspace,
search_type=search_type,
output_format="json",
@@ -649,9 +620,6 @@ def schema_validate(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
# Heuristic: if target contains / or ., treat as identifier; otherwise as note type
note_type, identifier = None, None
if target:
@@ -665,7 +633,7 @@ def schema_validate(
mcp_schema_validate(
note_type=note_type,
identifier=identifier,
project=project_name,
project=project,
workspace=workspace,
output_format="json",
)
@@ -717,15 +685,12 @@ def schema_infer(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_schema_infer(
note_type=entity_type,
threshold=threshold,
project=project_name,
project=project,
workspace=workspace,
output_format="json",
)
@@ -773,14 +738,11 @@ def schema_diff(
try:
validate_routing_flags(local, cloud)
config_manager = ConfigManager()
project_name = _resolve_project(config_manager, project)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_schema_diff(
note_type=entity_type,
project=project_name,
project=project,
workspace=workspace,
output_format="json",
)
+4 -2
View File
@@ -425,10 +425,12 @@ class BasicMemoryConfig(BaseSettings):
def get_project_mode(self, project_name: str) -> ProjectMode:
"""Get the routing mode for a project.
Returns the per-project mode if set, otherwise LOCAL.
Returns the per-project mode if set.
Unknown projects (not in local config) default to CLOUD —
local projects are always registered in config.
"""
entry = self.projects.get(project_name)
return entry.mode if entry else ProjectMode.LOCAL
return entry.mode if entry else ProjectMode.CLOUD
def set_project_mode(self, project_name: str, mode: ProjectMode) -> None:
"""Set the routing mode for a project.
+1 -1
View File
@@ -445,6 +445,7 @@ async def get_project_client(
# Step 3: Determine if cloud routing is needed
config = ConfigManager().config
project_entry = config.projects.get(resolved_project)
project_mode = config.get_project_mode(resolved_project)
# Trigger: workspace provided for a local project (without explicit --cloud)
@@ -459,7 +460,6 @@ async def get_project_client(
if project_mode == ProjectMode.CLOUD or (_explicit_routing() and not _force_local_mode()):
# --- Cloud routing: resolve workspace with priority chain ---
effective_workspace = workspace
project_entry = config.projects.get(resolved_project)
# Priority 2: per-project workspace_id from config
if effective_workspace is None and project_entry and project_entry.workspace_id:
+24 -137
View File
@@ -5,7 +5,7 @@ Tests mock the MCP tool functions directly.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
from typer.testing import CliRunner
@@ -79,28 +79,16 @@ SEARCH_RESULT = {
}
def _mock_config_manager():
"""Create a mock ConfigManager that avoids reading real config."""
mock_cm = MagicMock()
mock_cm.config = MagicMock()
mock_cm.default_project = "test-project"
mock_cm.get_project.return_value = ("test-project", "/tmp/test")
return mock_cm
# --- write-note ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_write_note_json_output(mock_mcp_write, mock_config_cls):
def test_write_note_json_output(mock_mcp_write):
"""write-note outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
[
@@ -125,16 +113,13 @@ def test_write_note_json_output(mock_mcp_write, mock_config_cls):
assert mock_mcp_write.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_write_note_with_tags(mock_mcp_write, mock_config_cls):
def test_write_note_with_tags(mock_mcp_write):
"""write-note passes tags through to MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
[
@@ -160,16 +145,13 @@ def test_write_note_with_tags(mock_mcp_write, mock_config_cls):
# --- read-note ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
def test_read_note_json_output(mock_mcp_read, mock_config_cls):
def test_read_note_json_output(mock_mcp_read):
"""read-note outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note"],
@@ -185,16 +167,13 @@ def test_read_note_json_output(mock_mcp_read, mock_config_cls):
assert mock_mcp_read.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
def test_read_note_workspace_passthrough(mock_mcp_read):
"""read-note --workspace passes workspace through to the MCP tool call."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--workspace", "tenant-123"],
@@ -204,16 +183,13 @@ def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
assert mock_mcp_read.call_args.kwargs["workspace"] == "tenant-123"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
def test_read_note_include_frontmatter(mock_mcp_read, mock_config_cls):
def test_read_note_include_frontmatter(mock_mcp_read):
"""read-note --include-frontmatter passes flag through to MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--include-frontmatter"],
@@ -223,16 +199,13 @@ def test_read_note_include_frontmatter(mock_mcp_read, mock_config_cls):
assert mock_mcp_read.call_args.kwargs["include_frontmatter"] is True
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
def test_read_note_pagination(mock_mcp_read, mock_config_cls):
def test_read_note_pagination(mock_mcp_read):
"""read-note --page and --page-size are passed through."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--page", "2", "--page-size", "5"],
@@ -246,16 +219,13 @@ def test_read_note_pagination(mock_mcp_read, mock_config_cls):
# --- edit-note ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_edit_note",
new_callable=AsyncMock,
return_value=EDIT_NOTE_RESULT,
)
def test_edit_note_json_output(mock_mcp_edit, mock_config_cls):
def test_edit_note_json_output(mock_mcp_edit):
"""edit-note outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
[
@@ -277,16 +247,13 @@ def test_edit_note_json_output(mock_mcp_edit, mock_config_cls):
assert mock_mcp_edit.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_edit_note",
new_callable=AsyncMock,
return_value={"title": "Test", "permalink": "test", "error": "Edit failed: not found"},
)
def test_edit_note_error_response(mock_mcp_edit, mock_config_cls):
def test_edit_note_error_response(mock_mcp_edit):
"""edit-note exits with code 1 when MCP tool returns error field."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
[
@@ -306,16 +273,13 @@ def test_edit_note_error_response(mock_mcp_edit, mock_config_cls):
# --- build-context ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_build_context",
new_callable=AsyncMock,
return_value=BUILD_CONTEXT_RESULT,
)
def test_build_context_json_output(mock_build_ctx, mock_config_cls):
def test_build_context_json_output(mock_build_ctx):
"""build-context outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "build-context", "memory://test/topic"],
@@ -328,16 +292,13 @@ def test_build_context_json_output(mock_build_ctx, mock_config_cls):
assert mock_build_ctx.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_build_context",
new_callable=AsyncMock,
return_value=BUILD_CONTEXT_RESULT,
)
def test_build_context_with_options(mock_build_ctx, mock_config_cls):
def test_build_context_with_options(mock_build_ctx):
"""build-context passes depth, timeframe, pagination through."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
[
@@ -366,16 +327,13 @@ def test_build_context_with_options(mock_build_ctx, mock_config_cls):
# --- recent-activity ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value=RECENT_ACTIVITY_RESULT,
)
def test_recent_activity_json_output(mock_mcp_recent, mock_config_cls):
def test_recent_activity_json_output(mock_mcp_recent):
"""recent-activity outputs valid JSON list from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "recent-activity"],
@@ -391,16 +349,13 @@ def test_recent_activity_json_output(mock_mcp_recent, mock_config_cls):
assert mock_mcp_recent.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value=RECENT_ACTIVITY_RESULT,
)
def test_recent_activity_pagination(mock_mcp_recent, mock_config_cls):
def test_recent_activity_pagination(mock_mcp_recent):
"""recent-activity passes --page and --page-size through."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "recent-activity", "--page", "2", "--page-size", "10"],
@@ -412,16 +367,13 @@ def test_recent_activity_pagination(mock_mcp_recent, mock_config_cls):
assert kwargs["page_size"] == 10
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value=[],
)
def test_recent_activity_empty(mock_mcp_recent, mock_config_cls):
def test_recent_activity_empty(mock_mcp_recent):
"""recent-activity handles empty results."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "recent-activity"],
@@ -435,16 +387,13 @@ def test_recent_activity_empty(mock_mcp_recent, mock_config_cls):
# --- search-notes ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
def test_search_notes_json_output(mock_mcp_search, mock_config_cls):
def test_search_notes_json_output(mock_mcp_search):
"""search-notes outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "search-notes", "test query"],
@@ -458,16 +407,13 @@ def test_search_notes_json_output(mock_mcp_search, mock_config_cls):
assert mock_mcp_search.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
def test_search_notes_with_meta_filter(mock_mcp_search, mock_config_cls):
def test_search_notes_with_meta_filter(mock_mcp_search):
"""search-notes --meta key=value builds metadata filters."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "search-notes", "query", "--meta", "status=draft"],
@@ -477,16 +423,13 @@ def test_search_notes_with_meta_filter(mock_mcp_search, mock_config_cls):
assert mock_mcp_search.call_args.kwargs["metadata_filters"] == {"status": "draft"}
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
def test_search_notes_permalink_mode(mock_mcp_search, mock_config_cls):
def test_search_notes_permalink_mode(mock_mcp_search):
"""search-notes --permalink sets search_type."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "search-notes", "specs/*", "--permalink"],
@@ -496,16 +439,13 @@ def test_search_notes_permalink_mode(mock_mcp_search, mock_config_cls):
assert mock_mcp_search.call_args.kwargs["search_type"] == "permalink"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_search",
new_callable=AsyncMock,
return_value="Error: search failed",
)
def test_search_notes_string_error(mock_mcp_search, mock_config_cls):
def test_search_notes_string_error(mock_mcp_search):
"""search-notes exits with code 1 when MCP returns string error."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "search-notes", "query"],
@@ -514,41 +454,6 @@ def test_search_notes_string_error(mock_mcp_search, mock_config_cls):
assert result.exit_code == 1
# --- Project resolution ---
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
def test_project_not_found_error(mock_mcp_write, mock_config_cls):
"""Commands exit with error when specified project doesn't exist."""
mock_cm = _mock_config_manager()
mock_cm.get_project.return_value = (None, None)
mock_config_cls.return_value = mock_cm
result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Test",
"--folder",
"notes",
"--content",
"hello",
"--project",
"nonexistent",
],
)
assert result.exit_code == 1
assert "No project found" in result.output
# --- Routing flags ---
@@ -595,16 +500,13 @@ SCHEMA_VALIDATE_RESULT = {
}
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_schema_validate",
new_callable=AsyncMock,
return_value=SCHEMA_VALIDATE_RESULT,
)
def test_schema_validate_json_output(mock_mcp, mock_config_cls):
def test_schema_validate_json_output(mock_mcp):
"""schema-validate outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "schema-validate", "person"],
@@ -618,16 +520,13 @@ def test_schema_validate_json_output(mock_mcp, mock_config_cls):
assert mock_mcp.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_schema_validate",
new_callable=AsyncMock,
return_value=SCHEMA_VALIDATE_RESULT,
)
def test_schema_validate_identifier_heuristic(mock_mcp, mock_config_cls):
def test_schema_validate_identifier_heuristic(mock_mcp):
"""schema-validate treats target with / as identifier, not note_type."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "schema-validate", "people/alice.md"],
@@ -638,16 +537,13 @@ def test_schema_validate_identifier_heuristic(mock_mcp, mock_config_cls):
assert mock_mcp.call_args.kwargs["note_type"] is None
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_schema_validate",
new_callable=AsyncMock,
return_value={"error": "No notes found of type 'person'"},
)
def test_schema_validate_error_response(mock_mcp, mock_config_cls):
def test_schema_validate_error_response(mock_mcp):
"""schema-validate outputs error JSON when MCP returns error dict."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "schema-validate", "person"],
@@ -674,16 +570,13 @@ SCHEMA_INFER_RESULT = {
}
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_schema_infer",
new_callable=AsyncMock,
return_value=SCHEMA_INFER_RESULT,
)
def test_schema_infer_json_output(mock_mcp, mock_config_cls):
def test_schema_infer_json_output(mock_mcp):
"""schema-infer outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "schema-infer", "person"],
@@ -697,16 +590,13 @@ def test_schema_infer_json_output(mock_mcp, mock_config_cls):
assert mock_mcp.call_args.kwargs["output_format"] == "json"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_schema_infer",
new_callable=AsyncMock,
return_value=SCHEMA_INFER_RESULT,
)
def test_schema_infer_threshold_passthrough(mock_mcp, mock_config_cls):
def test_schema_infer_threshold_passthrough(mock_mcp):
"""schema-infer passes --threshold through to MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "schema-infer", "person", "--threshold", "0.5"],
@@ -729,16 +619,13 @@ SCHEMA_DIFF_RESULT = {
}
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_schema_diff",
new_callable=AsyncMock,
return_value=SCHEMA_DIFF_RESULT,
)
def test_schema_diff_json_output(mock_mcp, mock_config_cls):
def test_schema_diff_json_output(mock_mcp):
"""schema-diff outputs valid JSON from MCP tool."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "schema-diff", "person"],
+1
View File
@@ -128,6 +128,7 @@ async def test_get_client_local_project_uses_asgi_transport(config_manager):
"""Local-mode project uses ASGI transport even if API key exists."""
cfg = config_manager.load_config()
cfg.cloud_api_key = "bmc_test_key_123"
cfg.set_project_mode("main", ProjectMode.LOCAL)
config_manager.save_config(cfg)
async with get_client(project_name="main") as client:
+47 -1
View File
@@ -266,8 +266,15 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
@pytest.mark.asyncio
async def test_get_project_client_rejects_workspace_for_local_project():
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"
@@ -429,3 +436,42 @@ class TestGetProjectClientRoutingOrder:
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
+7 -1
View File
@@ -148,6 +148,7 @@ async def test_run_filters_cloud_projects_each_cycle(monkeypatch, tmp_path):
config = BasicMemoryConfig(
watch_project_reload_interval=1,
projects={
"local-project": {"path": str(tmp_path / "local"), "mode": "local"},
"cloud-project": {"path": str(tmp_path / "cloud"), "mode": "cloud"},
},
)
@@ -258,7 +259,12 @@ async def test_timer_task_cancelled_properly(monkeypatch, tmp_path):
@pytest.mark.asyncio
async def test_new_project_addition_scenario(monkeypatch, tmp_path):
config = BasicMemoryConfig()
config = BasicMemoryConfig(
projects={
"existing": {"path": str(tmp_path / "existing"), "mode": "local"},
"new": {"path": str(tmp_path / "new"), "mode": "local"},
},
)
initial_projects = [
Project(id=1, name="existing", path=str(tmp_path / "existing"), permalink="existing")
+7 -3
View File
@@ -830,10 +830,14 @@ class TestProjectMode:
assert ProjectMode.LOCAL.value == "local"
assert ProjectMode.CLOUD.value == "cloud"
def test_get_project_mode_defaults_to_local(self):
"""Test that unknown projects default to LOCAL mode."""
def test_get_project_mode_defaults_to_cloud(self):
"""Test that unknown projects default to CLOUD mode.
Unknown projects are not registered in local config, so they
are assumed to be cloud-only projects discovered from the API.
"""
config = BasicMemoryConfig()
assert config.get_project_mode("nonexistent") == ProjectMode.LOCAL
assert config.get_project_mode("nonexistent") == ProjectMode.CLOUD
def test_set_project_mode_cloud(self):
"""Test setting a project to cloud mode."""