mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
c44291830c
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Integration tests for CLI routing flags (--local/--cloud).
|
|
|
|
These tests verify that the --local and --cloud flags work correctly
|
|
across CLI commands, and that MCP routing varies by transport.
|
|
|
|
Note: Environment variable behavior during command execution is tested
|
|
in unit tests (tests/cli/test_routing.py) which can properly monkeypatch
|
|
the modules before they are imported. These integration tests focus on
|
|
CLI behavior: flag acceptance and error handling.
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from basic_memory.cli.main import app as cli_app
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class TestRoutingFlagsValidation:
|
|
"""Tests for --local/--cloud flag validation.
|
|
|
|
These tests verify that using both --local and --cloud together
|
|
produces an appropriate error message.
|
|
"""
|
|
|
|
def test_status_both_flags_error(self):
|
|
"""Using both --local and --cloud should produce an error."""
|
|
result = runner.invoke(cli_app, ["status", "--local", "--cloud"])
|
|
# Exit code can be 1 or 2 depending on how typer handles the exception
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
def test_project_list_both_flags_error(self):
|
|
"""Using both --local and --cloud should produce an error."""
|
|
result = runner.invoke(cli_app, ["project", "list", "--local", "--cloud"])
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
def test_project_ls_both_flags_error(self):
|
|
"""Using both --local and --cloud on project ls should produce an error."""
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["project", "ls", "--name", "test", "--local", "--cloud"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
def test_tool_search_both_flags_error(self):
|
|
"""Using both --local and --cloud should produce an error."""
|
|
result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--local", "--cloud"])
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
def test_tool_read_note_both_flags_error(self):
|
|
"""Using both --local and --cloud should produce an error."""
|
|
result = runner.invoke(cli_app, ["tool", "read-note", "test", "--local", "--cloud"])
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
def test_tool_build_context_both_flags_error(self):
|
|
"""Using both --local and --cloud should produce an error."""
|
|
result = runner.invoke(
|
|
cli_app, ["tool", "build-context", "memory://test", "--local", "--cloud"]
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
def test_tool_edit_note_both_flags_error(self):
|
|
"""Using both --local and --cloud should produce an error."""
|
|
result = runner.invoke(
|
|
cli_app,
|
|
[
|
|
"tool",
|
|
"edit-note",
|
|
"test",
|
|
"--operation",
|
|
"append",
|
|
"--content",
|
|
"test",
|
|
"--local",
|
|
"--cloud",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Cannot specify both --local and --cloud" in result.output
|
|
|
|
|
|
class TestMcpCommandRouting:
|
|
"""Tests that MCP routing varies by transport."""
|
|
|
|
def test_mcp_stdio_does_not_force_local(self, monkeypatch):
|
|
"""Stdio transport should not inject explicit local routing env vars."""
|
|
# Ensure env is clean before test
|
|
monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False)
|
|
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
|
|
|
|
env_at_run = {}
|
|
|
|
import basic_memory.cli.commands.mcp as mcp_mod
|
|
|
|
def mock_run(*args, **kwargs):
|
|
env_at_run["FORCE_LOCAL"] = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
|
|
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
|
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
|
|
|
runner.invoke(cli_app, ["mcp"]) # default transport is stdio
|
|
|
|
# Command should not have set these vars
|
|
assert env_at_run["FORCE_LOCAL"] is None
|
|
assert env_at_run["EXPLICIT"] is None
|
|
|
|
def test_mcp_stdio_honors_external_env_override(self, monkeypatch):
|
|
"""Stdio transport should pass through externally-set routing env vars."""
|
|
monkeypatch.setenv("BASIC_MEMORY_FORCE_CLOUD", "true")
|
|
monkeypatch.setenv("BASIC_MEMORY_EXPLICIT_ROUTING", "true")
|
|
|
|
env_at_run = {}
|
|
|
|
import basic_memory.cli.commands.mcp as mcp_mod
|
|
|
|
def mock_run(*args, **kwargs):
|
|
env_at_run["FORCE_CLOUD"] = os.environ.get("BASIC_MEMORY_FORCE_CLOUD")
|
|
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
|
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
|
|
|
runner.invoke(cli_app, ["mcp"])
|
|
|
|
# Externally-set vars should be preserved
|
|
assert env_at_run["FORCE_CLOUD"] == "true"
|
|
assert env_at_run["EXPLICIT"] == "true"
|
|
|
|
def test_mcp_streamable_http_forces_local(self, monkeypatch):
|
|
"""Streamable-HTTP transport should force local routing."""
|
|
env_at_run = {}
|
|
|
|
import basic_memory.cli.commands.mcp as mcp_mod
|
|
|
|
def mock_run(*args, **kwargs):
|
|
env_at_run["FORCE_LOCAL"] = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
|
|
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
|
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
|
|
|
runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
|
|
|
|
assert env_at_run["FORCE_LOCAL"] == "true"
|
|
assert env_at_run["EXPLICIT"] == "true"
|
|
|
|
def test_mcp_sse_forces_local(self, monkeypatch):
|
|
"""SSE transport should force local routing."""
|
|
env_at_run = {}
|
|
|
|
import basic_memory.cli.commands.mcp as mcp_mod
|
|
|
|
def mock_run(*args, **kwargs):
|
|
env_at_run["FORCE_LOCAL"] = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
|
|
env_at_run["EXPLICIT"] = os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING")
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
|
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
|
|
|
runner.invoke(cli_app, ["mcp", "--transport", "sse"])
|
|
|
|
assert env_at_run["FORCE_LOCAL"] == "true"
|
|
assert env_at_run["EXPLICIT"] == "true"
|
|
|
|
|
|
class TestToolCommandsAcceptFlags:
|
|
"""Tests that tool commands accept routing flags without parsing errors."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"command,args",
|
|
[
|
|
("search-notes", ["test query"]),
|
|
("recent-activity", []),
|
|
("read-note", ["test"]),
|
|
("edit-note", ["test", "--operation", "append", "--content", "test"]),
|
|
("build-context", ["memory://test"]),
|
|
],
|
|
)
|
|
def test_tool_commands_accept_local_flag(self, command, args, app_config):
|
|
"""Tool commands should accept --local flag without parsing error."""
|
|
full_args = ["tool", command] + args + ["--local"]
|
|
result = runner.invoke(cli_app, full_args)
|
|
# Should not fail due to flag parsing (No such option error)
|
|
assert "No such option: --local" not in result.output
|
|
|
|
@pytest.mark.parametrize(
|
|
"command,args",
|
|
[
|
|
("search-notes", ["test query"]),
|
|
("recent-activity", []),
|
|
("read-note", ["test"]),
|
|
("edit-note", ["test", "--operation", "append", "--content", "test"]),
|
|
("build-context", ["memory://test"]),
|
|
],
|
|
)
|
|
def test_tool_commands_accept_cloud_flag(self, command, args, app_config):
|
|
"""Tool commands should accept --cloud flag without parsing error."""
|
|
full_args = ["tool", command] + args + ["--cloud"]
|
|
result = runner.invoke(cli_app, full_args)
|
|
# Should not fail due to flag parsing (No such option error)
|
|
assert "No such option: --cloud" not in result.output
|
|
|
|
|
|
class TestProjectCommandsAcceptFlags:
|
|
"""Tests that project commands accept routing flags without parsing errors."""
|
|
|
|
def test_project_list_accepts_local_flag(self, app_config):
|
|
"""project list should accept --local flag."""
|
|
result = runner.invoke(cli_app, ["project", "list", "--local"])
|
|
assert "No such option: --local" not in result.output
|
|
|
|
def test_project_list_accepts_cloud_flag(self, app_config):
|
|
"""project list should accept --cloud flag."""
|
|
result = runner.invoke(cli_app, ["project", "list", "--cloud"])
|
|
assert "No such option: --cloud" not in result.output
|
|
|
|
def test_project_info_accepts_local_flag(self, app_config):
|
|
"""project info should accept --local flag."""
|
|
result = runner.invoke(cli_app, ["project", "info", "--local"])
|
|
assert "No such option: --local" not in result.output
|
|
|
|
def test_project_info_accepts_cloud_flag(self, app_config):
|
|
"""project info should accept --cloud flag."""
|
|
result = runner.invoke(cli_app, ["project", "info", "--cloud"])
|
|
assert "No such option: --cloud" not in result.output
|
|
|
|
def test_project_default_accepts_local_flag(self, app_config):
|
|
"""project default should accept --local flag."""
|
|
result = runner.invoke(cli_app, ["project", "default", "test", "--local"])
|
|
assert "No such option: --local" not in result.output
|
|
|
|
def test_project_sync_config_accepts_local_flag(self, app_config):
|
|
"""project sync-config should accept --local flag."""
|
|
result = runner.invoke(cli_app, ["project", "sync-config", "test", "--local"])
|
|
assert "No such option: --local" not in result.output
|
|
|
|
def test_project_move_local_only(self, app_config):
|
|
"""project move should reject --cloud flag."""
|
|
result = runner.invoke(cli_app, ["project", "move", "test", "/tmp/dest", "--cloud"])
|
|
assert result.exit_code == 2
|
|
|
|
def test_project_ls_accepts_local_flag(self, app_config):
|
|
"""project ls should accept --local flag."""
|
|
result = runner.invoke(cli_app, ["project", "ls", "--name", "test", "--local"])
|
|
assert "No such option: --local" not in result.output
|
|
|
|
def test_project_ls_accepts_cloud_flag(self, app_config):
|
|
"""project ls should accept --cloud flag."""
|
|
result = runner.invoke(cli_app, ["project", "ls", "--name", "test", "--cloud"])
|
|
assert "No such option: --cloud" not in result.output
|
|
|
|
|
|
class TestStatusCommandAcceptsFlags:
|
|
"""Tests that status command accepts routing flags."""
|
|
|
|
def test_status_accepts_local_flag(self, app_config):
|
|
"""status should accept --local flag."""
|
|
result = runner.invoke(cli_app, ["status", "--local"])
|
|
assert "No such option: --local" not in result.output
|
|
|
|
def test_status_accepts_cloud_flag(self, app_config):
|
|
"""status should accept --cloud flag."""
|
|
result = runner.invoke(cli_app, ["status", "--cloud"])
|
|
assert "No such option: --cloud" not in result.output
|