Files
basicmachines-co-basic-memory/tests/test_config.py
Aarish Alam b6e8c636ce feat(core): add LiteLLM embedding provider (#809)
Adds LiteLLM as a semantic embedding provider, including provider configuration, vector normalization, live-provider evaluation tooling, and documentation for OpenAI, Cohere, Azure Foundry, Azure OpenAI, and NVIDIA NIM-style cases.

Maintainer follow-up on this PR added provider hardening, asymmetric document/query embedding support, dimension-forwarding controls, SQLite/Postgres vector invalidation coverage, and the repeatable live LiteLLM harness.

Verification:
- Full base-repo Tests workflow passed for 3d4e092ceb: https://github.com/basicmachines-co/basic-memory/actions/runs/27072071785
- Live LiteLLM harness passed locally for OpenAI, Cohere, and Azure Foundry.

Co-authored-by: Aarish Alam <arishalam121@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: RheagalFire <arishalam121@gmail.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-06 17:13:09 -05:00

1603 lines
69 KiB
Python

"""Test configuration management."""
import os
import stat
import tempfile
import pytest
from datetime import datetime
from typing import Any, cast
from basic_memory.config import (
BasicMemoryConfig,
ConfigManager,
ProjectEntry,
ProjectMode,
default_fastembed_cache_dir,
resolve_data_dir,
)
from pathlib import Path
def _migrate_legacy_projects(data: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], cast(Any, BasicMemoryConfig.migrate_legacy_projects)(data))
class TestBasicMemoryConfig:
"""Test BasicMemoryConfig behavior with BASIC_MEMORY_HOME environment variable."""
def test_default_behavior_without_basic_memory_home(self, config_home, monkeypatch):
"""Test that config uses default path when BASIC_MEMORY_HOME is not set."""
# Ensure BASIC_MEMORY_HOME is not set
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig()
# Should use the default path (home/basic-memory)
expected_path = config_home / "basic-memory"
assert Path(config.projects["main"].path) == expected_path
assert config.default_project == "main"
def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch):
"""Test that config respects BASIC_MEMORY_HOME environment variable."""
custom_path = config_home / "app" / "data"
monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path))
config = BasicMemoryConfig()
# Should use the custom path from environment variable
assert Path(config.projects["main"].path) == custom_path
def test_model_post_init_respects_basic_memory_home_creates_main(
self, config_home, monkeypatch
):
"""Test that model_post_init creates main project with BASIC_MEMORY_HOME when missing and no other projects."""
custom_path = config_home / "custom" / "memory" / "path"
monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path))
# Create config without main project
config = BasicMemoryConfig()
# model_post_init should have added main project with BASIC_MEMORY_HOME
assert "main" in config.projects
assert Path(config.projects["main"].path) == custom_path
def test_model_post_init_respects_basic_memory_home_sets_non_main_default(
self, config_home, monkeypatch
):
"""Test that model_post_init does not create main project with BASIC_MEMORY_HOME when another project exists."""
custom_path = config_home / "custom" / "memory" / "path"
monkeypatch.setenv("BASIC_MEMORY_HOME", str(custom_path))
# Create config without main project
other_path = config_home / "some" / "path"
config = BasicMemoryConfig(projects={"other": {"path": str(other_path)}})
# model_post_init should not add main project with BASIC_MEMORY_HOME
assert "main" not in config.projects
assert Path(config.projects["other"].path) == other_path
assert config.default_project == "other"
def test_model_post_init_fallback_without_basic_memory_home(self, config_home, monkeypatch):
"""Test that model_post_init can set a non-main default when BASIC_MEMORY_HOME is not set."""
# Ensure BASIC_MEMORY_HOME is not set
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
# Create config without main project
other_path = config_home / "some" / "path"
config = BasicMemoryConfig(projects={"other": {"path": str(other_path)}})
# model_post_init should not add main project, but "other" should now be the default
assert "main" not in config.projects
assert Path(config.projects["other"].path) == other_path
assert config.default_project == "other"
def test_basic_memory_home_with_relative_path(self, config_home, monkeypatch):
"""Test that BASIC_MEMORY_HOME works with relative paths."""
relative_path = "relative/memory/path"
monkeypatch.setenv("BASIC_MEMORY_HOME", relative_path)
config = BasicMemoryConfig()
# Should normalize to platform-native path format
assert Path(config.projects["main"].path) == Path(relative_path)
def test_basic_memory_home_overrides_existing_main_project(self, config_home, monkeypatch):
"""Test that BASIC_MEMORY_HOME is not used when a map is passed in the constructor."""
custom_path = str(config_home / "override" / "memory" / "path")
monkeypatch.setenv("BASIC_MEMORY_HOME", custom_path)
# Try to create config with a different main project path
original_path = str(config_home / "original" / "path")
config = BasicMemoryConfig(projects={"main": {"path": original_path}})
# The default_factory should override with BASIC_MEMORY_HOME value
# Note: This tests the current behavior where default_factory takes precedence
assert config.projects["main"].path == original_path
def test_app_database_path_uses_custom_config_dir(self, tmp_path, monkeypatch):
"""Default SQLite DB should live under BASIC_MEMORY_CONFIG_DIR when set."""
custom_config_dir = tmp_path / "instance-a" / "state"
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_config_dir))
config = BasicMemoryConfig(projects={"main": {"path": str(tmp_path / "project")}})
assert config.data_dir_path == custom_config_dir
assert config.app_database_path == custom_config_dir / "memory.db"
assert config.app_database_path.exists()
def test_app_database_path_defaults_to_home_data_dir(self, config_home, monkeypatch):
"""Without BASIC_MEMORY_CONFIG_DIR, default DB stays at ~/.basic-memory/memory.db."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
config = BasicMemoryConfig()
assert config.data_dir_path == config_home / ".basic-memory"
assert config.app_database_path == config_home / ".basic-memory" / "memory.db"
def test_semantic_embedding_cache_dir_field_stays_none_by_default(
self, config_home, monkeypatch
):
"""The raw config field stays None so it isn't persisted into config.json.
Resolution to a concrete path happens in embedding_provider_factory at
provider construction time, so ``BASIC_MEMORY_CONFIG_DIR`` and
``FASTEMBED_CACHE_PATH`` changes take effect on every run instead of
being frozen by the first save. See #741.
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
config = BasicMemoryConfig()
assert config.semantic_embedding_cache_dir is None
def test_semantic_embedding_cache_dir_not_persisted_in_model_dump(
self, config_home, monkeypatch
):
"""model_dump must not bake a resolved cache path into config.json.
Regression guard for #741: persisting the default would freeze stale
paths when users later change BASIC_MEMORY_CONFIG_DIR or
FASTEMBED_CACHE_PATH.
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
dumped = BasicMemoryConfig().model_dump(mode="json")
assert dumped["semantic_embedding_cache_dir"] is None
def test_semantic_embedding_cache_dir_explicit_user_value_preserved(
self, config_home, monkeypatch
):
"""An explicit user override still round-trips through model_dump."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
config = BasicMemoryConfig(semantic_embedding_cache_dir="/custom/explicit/path")
assert config.semantic_embedding_cache_dir == "/custom/explicit/path"
assert (
config.model_dump(mode="json")["semantic_embedding_cache_dir"]
== "/custom/explicit/path"
)
def test_explicit_default_project_preserved(self, config_home, monkeypatch):
"""Test that a valid explicit default_project is not overwritten by model_post_init."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={
"alpha": {"path": str(config_home / "alpha")},
"beta": {"path": str(config_home / "beta")},
},
default_project="beta",
)
assert config.default_project == "beta"
def test_invalid_default_project_corrected(self, config_home, monkeypatch):
"""Test that an invalid default_project is corrected to the first project."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={
"alpha": {"path": str(config_home / "alpha")},
"beta": {"path": str(config_home / "beta")},
},
default_project="nonexistent",
)
assert config.default_project == "alpha"
def test_no_default_project_key_uses_first_project(self, config_home, monkeypatch):
"""Test that config without default_project key sets it to the first project."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
# Simulate loading a config file that has no default_project key —
# the field default (None) kicks in, and model_post_init resolves it
config = BasicMemoryConfig(
projects={
"research": {"path": str(config_home / "research")},
"notes": {"path": str(config_home / "notes")},
},
)
assert config.default_project == "research"
def test_empty_string_default_project_corrected(self, config_home, monkeypatch):
"""Test that an empty-string default_project is corrected to the first project."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={
"alpha": {"path": str(config_home / "alpha")},
},
default_project="",
)
# Empty string is not in projects, so model_post_init corrects it
assert config.default_project == "alpha"
def test_single_project_default_always_matches(self, config_home, monkeypatch):
"""Test that a config with one project always resolves default_project to it."""
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config = BasicMemoryConfig(
projects={"only": {"path": str(config_home / "only")}},
)
assert config.default_project == "only"
def test_stale_default_project_loaded_from_file(self, config_home, monkeypatch):
"""Test that a config file with a stale default_project is corrected on load."""
import json
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config_manager = ConfigManager()
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Write a config file where default_project references a removed project
config_data = {
"projects": {
"research": {"path": str(config_home / "research")},
"notes": {"path": str(config_home / "notes")},
},
"default_project": "deleted-project",
}
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
loaded = config_manager.load_config()
assert loaded.default_project == "research"
def test_config_file_without_default_project_key(self, config_home, monkeypatch):
"""Test that a config file with no default_project key resolves dynamically."""
import json
import basic_memory.config
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
config_manager = ConfigManager()
config_manager.config_dir = config_home / ".basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Write a config file that deliberately omits default_project
config_data = {
"projects": {
"work": {"path": str(config_home / "work")},
"personal": {"path": str(config_home / "personal")},
},
}
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
loaded = config_manager.load_config()
assert loaded.default_project == "work"
class TestDataDirHelpers:
"""Module-level helpers that resolve the Basic Memory data directory."""
def test_resolve_data_dir_defaults_to_home_dot_basic_memory(self, config_home, monkeypatch):
"""Without BASIC_MEMORY_CONFIG_DIR and XDG_CONFIG_HOME, resolver returns ~/.basic-memory."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
assert resolve_data_dir() == config_home / ".basic-memory"
def test_resolve_data_dir_honors_config_dir_env(self, tmp_path, monkeypatch):
"""BASIC_MEMORY_CONFIG_DIR overrides the default location."""
custom = tmp_path / "elsewhere"
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom))
assert resolve_data_dir() == custom
def test_resolve_data_dir_honors_xdg_config_home(self, tmp_path, monkeypatch):
"""XDG_CONFIG_HOME is honored when BASIC_MEMORY_CONFIG_DIR is not set."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
xdg_config = tmp_path / "xdg-config"
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config))
assert resolve_data_dir() == xdg_config / "basic-memory"
def test_basic_memory_config_dir_takes_precedence_over_xdg(self, tmp_path, monkeypatch):
"""BASIC_MEMORY_CONFIG_DIR takes precedence over XDG_CONFIG_HOME."""
xdg_config = tmp_path / "xdg-config"
custom = tmp_path / "custom-config"
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config))
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom))
assert resolve_data_dir() == custom
def test_default_fastembed_cache_dir_uses_data_dir(self, config_home, monkeypatch):
"""Default cache path is a subdir of the Basic Memory data dir."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
assert default_fastembed_cache_dir() == str(
config_home / ".basic-memory" / "fastembed_cache"
)
def test_default_fastembed_cache_dir_env_override(self, tmp_path, monkeypatch):
"""FASTEMBED_CACHE_PATH is preferred when set."""
custom = tmp_path / "custom-cache"
monkeypatch.setenv("FASTEMBED_CACHE_PATH", str(custom))
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "state"))
assert default_fastembed_cache_dir() == str(custom)
def test_default_fastembed_cache_dir_never_falls_back_to_fastembed_tmp_default(
self, config_home, monkeypatch
):
"""Regression guard for #741.
FastEmbed's own fallback when ``cache_dir`` is ``None`` is
``<system tmp>/fastembed_cache`` — the exact path that disappears in
sandboxed MCP runtimes (Codex CLI). Ensure Basic Memory's resolver
never lands on that path.
Compared as exact paths rather than ``startswith(tempfile.gettempdir())``
because the test runner itself can legitimately live under ``/tmp``
(pytest's ``tmp_path`` does on Linux CI), and that's not the bug we
care about here.
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
resolved = Path(default_fastembed_cache_dir())
# Must equal the data-dir-relative default.
assert resolved == config_home / ".basic-memory" / "fastembed_cache"
# And must not equal FastEmbed's own <tempdir>/fastembed_cache fallback.
assert resolved != Path(tempfile.gettempdir()) / "fastembed_cache"
class TestConfigManager:
"""Test ConfigManager functionality."""
@pytest.fixture
def temp_config_manager(self):
"""Create a ConfigManager with temporary config file."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create a test ConfigManager instance
config_manager = ConfigManager()
# Override config paths to use temp directory
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.yaml"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create initial config with test projects
test_config = BasicMemoryConfig(
default_project="main",
projects={
"main": {"path": str(temp_path / "main")},
"test-project": {"path": str(temp_path / "test")},
"special-chars": {
"path": str(temp_path / "special")
}, # This will be the config key for "Special/Chars"
},
)
config_manager.save_config(test_config)
yield config_manager
def test_set_default_project_with_exact_name_match(self, temp_config_manager):
"""Test set_default_project when project name matches config key exactly."""
config_manager = temp_config_manager
# Set default to a project that exists with exact name match
config_manager.set_default_project("test-project")
# Verify the config was updated
config = config_manager.load_config()
assert config.default_project == "test-project"
def test_set_default_project_with_permalink_lookup(self, temp_config_manager):
"""Test set_default_project when input needs permalink normalization."""
config_manager = temp_config_manager
# Simulate a project that was created with special characters
# The config key would be the permalink, but user might type the original name
# First add a project with original name that gets normalized
config = config_manager.load_config()
config.projects["special-chars-project"] = ProjectEntry(path=str(Path("/tmp/special")))
config_manager.save_config(config)
# Now test setting default using a name that will normalize to the config key
config_manager.set_default_project(
"Special Chars Project"
) # This should normalize to "special-chars-project"
# Verify the config was updated with the correct config key
updated_config = config_manager.load_config()
assert updated_config.default_project == "special-chars-project"
def test_set_default_project_uses_canonical_name(self, temp_config_manager):
"""Test that set_default_project uses the canonical config key, not user input."""
config_manager = temp_config_manager
# Add a project with a config key that differs from user input
config = config_manager.load_config()
config.projects["my-test-project"] = ProjectEntry(path=str(Path("/tmp/mytest")))
config_manager.save_config(config)
# Set default using input that will match but is different from config key
config_manager.set_default_project("My Test Project") # Should find "my-test-project"
# Verify that the canonical config key is used, not the user input
updated_config = config_manager.load_config()
assert updated_config.default_project == "my-test-project"
# Should NOT be the user input
assert updated_config.default_project != "My Test Project"
def test_set_default_project_nonexistent_project(self, temp_config_manager):
"""Test set_default_project raises ValueError for nonexistent project."""
config_manager = temp_config_manager
with pytest.raises(ValueError, match="Project 'nonexistent' not found"):
config_manager.set_default_project("nonexistent")
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits are not portable to Windows")
def test_save_config_uses_private_permissions(self, temp_config_manager):
"""Config can contain cloud credentials, so writes should enforce private modes."""
config_manager = temp_config_manager
config = config_manager.load_config()
config.cloud_api_key = "bmc_test123"
config_manager.config_dir.chmod(0o777)
config_manager.config_file.chmod(0o666)
config_manager.save_config(config)
dir_mode = stat.S_IMODE(config_manager.config_dir.stat().st_mode)
file_mode = stat.S_IMODE(config_manager.config_file.stat().st_mode)
assert dir_mode == 0o700
assert file_mode == 0o600
def test_disable_permalinks_flag_default(self):
"""Test that disable_permalinks flag defaults to False."""
config = BasicMemoryConfig()
assert config.disable_permalinks is False
def test_disable_permalinks_flag_can_be_enabled(self):
"""Test that disable_permalinks flag can be set to True."""
config = BasicMemoryConfig(disable_permalinks=True)
assert config.disable_permalinks is True
def test_ensure_frontmatter_on_sync_flag_default(self):
"""Test that ensure_frontmatter_on_sync defaults to True."""
config = BasicMemoryConfig()
assert config.ensure_frontmatter_on_sync is True
def test_ensure_frontmatter_on_sync_flag_can_be_disabled(self):
"""Test that ensure_frontmatter_on_sync can be set to False."""
config = BasicMemoryConfig(ensure_frontmatter_on_sync=False)
assert config.ensure_frontmatter_on_sync is False
def test_permalinks_include_project_flag_default(self):
"""Test that permalinks_include_project defaults to True."""
config = BasicMemoryConfig()
assert config.permalinks_include_project is True
def test_permalinks_include_project_flag_can_be_disabled(self):
"""Test that permalinks_include_project can be set to False."""
config = BasicMemoryConfig(permalinks_include_project=False)
assert config.permalinks_include_project is False
def test_config_manager_respects_custom_config_dir(self, monkeypatch):
"""Test that ConfigManager respects BASIC_MEMORY_CONFIG_DIR environment variable."""
with tempfile.TemporaryDirectory() as temp_dir:
custom_config_dir = Path(temp_dir) / "custom" / "config"
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_config_dir))
config_manager = ConfigManager()
# Verify config_dir is set to the custom path
assert config_manager.config_dir == custom_config_dir
# Verify config_file is in the custom directory
assert config_manager.config_file == custom_config_dir / "config.json"
# Verify the directory was created
assert config_manager.config_dir.exists()
def test_config_manager_default_without_custom_config_dir(self, config_home, monkeypatch):
"""Test that ConfigManager uses default location when BASIC_MEMORY_CONFIG_DIR is not set."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
config_manager = ConfigManager()
# Should use default location
assert config_manager.config_dir == config_home / ".basic-memory"
assert config_manager.config_file == config_home / ".basic-memory" / "config.json"
def test_remove_project_with_exact_name_match(self, temp_config_manager):
"""Test remove_project when project name matches config key exactly."""
config_manager = temp_config_manager
# Verify project exists
config = config_manager.load_config()
assert "test-project" in config.projects
# Remove the project with exact name match
config_manager.remove_project("test-project")
# Verify the project was removed
config = config_manager.load_config()
assert "test-project" not in config.projects
def test_remove_project_with_permalink_lookup(self, temp_config_manager):
"""Test remove_project when input needs permalink normalization."""
config_manager = temp_config_manager
# Add a project with normalized key
config = config_manager.load_config()
config.projects["special-chars-project"] = ProjectEntry(path=str(Path("/tmp/special")))
config_manager.save_config(config)
# Remove using a name that will normalize to the config key
config_manager.remove_project(
"Special Chars Project"
) # This should normalize to "special-chars-project"
# Verify the project was removed using the correct config key
updated_config = config_manager.load_config()
assert "special-chars-project" not in updated_config.projects
def test_remove_project_uses_canonical_name(self, temp_config_manager):
"""Test that remove_project uses the canonical config key, not user input."""
config_manager = temp_config_manager
# Add a project with a config key that differs from user input
config = config_manager.load_config()
config.projects["my-test-project"] = ProjectEntry(path=str(Path("/tmp/mytest")))
config_manager.save_config(config)
# Remove using input that will match but is different from config key
config_manager.remove_project("My Test Project") # Should find "my-test-project"
# Verify that the canonical config key was removed
updated_config = config_manager.load_config()
assert "my-test-project" not in updated_config.projects
def test_remove_project_nonexistent_project(self, temp_config_manager):
"""Test remove_project raises ValueError for nonexistent project."""
config_manager = temp_config_manager
with pytest.raises(ValueError, match="Project 'nonexistent' not found"):
config_manager.remove_project("nonexistent")
def test_remove_project_cannot_remove_default(self, temp_config_manager):
"""Test remove_project raises ValueError when trying to remove default project."""
config_manager = temp_config_manager
# Try to remove the default project
with pytest.raises(ValueError, match="Cannot remove the default project"):
config_manager.remove_project("main")
def test_config_project_entry_cloud_sync_defaults(self, temp_config_manager):
"""Test that ProjectEntry cloud sync fields default to None/False."""
config_manager = temp_config_manager
config = config_manager.load_config()
entry = config.projects["main"]
assert entry.local_sync_path is None
assert entry.bisync_initialized is False
assert entry.last_sync is None
def test_save_and_load_config_with_cloud_sync_fields(self):
"""Test that config with cloud sync fields can be saved and loaded."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create config with cloud sync fields on a project entry
now = datetime.now()
test_config = BasicMemoryConfig(
projects={
"main": {"path": str(temp_path / "main")},
"research": {
"path": str(temp_path / "research"),
"mode": "cloud",
"local_sync_path": str(temp_path / "research-local"),
"last_sync": now.isoformat(),
"bisync_initialized": True,
},
},
)
config_manager.save_config(test_config)
# Load and verify
loaded_config = config_manager.load_config()
assert "research" in loaded_config.projects
entry = loaded_config.projects["research"]
assert entry.local_sync_path == str(temp_path / "research-local")
assert entry.bisync_initialized is True
assert entry.last_sync == now
def test_add_cloud_sync_to_existing_project(self):
"""Test adding cloud sync fields to an existing project entry."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create initial config without cloud sync fields
initial_config = BasicMemoryConfig(projects={"main": {"path": str(temp_path / "main")}})
config_manager.save_config(initial_config)
# Load, modify, and save
config = config_manager.load_config()
assert config.projects["main"].local_sync_path is None
config.projects["main"].local_sync_path = str(temp_path / "work-local")
config_manager.save_config(config)
# Reload and verify persistence
reloaded_config = config_manager.load_config()
assert reloaded_config.projects["main"].local_sync_path == str(temp_path / "work-local")
assert reloaded_config.projects["main"].bisync_initialized is False
def test_backward_compatibility_loading_old_format_config(self):
"""Test that old config files with Dict[str, str] projects can be loaded and migrated."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Manually write old-style config with Dict[str, str] projects
import json
old_config_data = {
"env": "dev",
"projects": {"main": str(temp_path / "main")},
"default_project": "main",
"log_level": "INFO",
}
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
# Clear the config cache to ensure we load from the temp file
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
# Should load successfully with migration to ProjectEntry
config = config_manager.load_config()
assert isinstance(config.projects["main"], ProjectEntry)
assert config.projects["main"].path == str(temp_path / "main")
assert config.projects["main"].mode == ProjectMode.LOCAL
def test_backward_compatibility_migrates_project_modes_and_cloud_projects(self):
"""Test that old config with project_modes and cloud_projects is migrated."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
import json
old_config_data = {
"env": "dev",
"projects": {
"main": str(temp_path / "main"),
"research": str(temp_path / "research"),
},
"default_project": "main",
"project_modes": {"research": "cloud"},
"cloud_projects": {
"research": {
"local_path": str(temp_path / "research-local"),
"bisync_initialized": True,
"last_sync": "2026-02-06T17:36:38",
}
},
}
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
config = config_manager.load_config()
# Verify migration
assert config.projects["research"].mode == ProjectMode.CLOUD
assert config.projects["research"].local_sync_path == str(temp_path / "research-local")
assert config.projects["research"].bisync_initialized is True
assert config.projects["main"].mode == ProjectMode.LOCAL
def test_legacy_cloud_mode_key_is_stripped_on_normalization_save(self):
"""Legacy cloud_mode should be removed from config.json after load/save normalization."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
import json
legacy_config = {
"env": "dev",
"projects": {"main": str(temp_path / "main")},
"default_project": "main",
"cloud_mode": True,
}
config_manager.config_file.write_text(json.dumps(legacy_config, indent=2))
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
loaded = config_manager.load_config()
assert isinstance(loaded, BasicMemoryConfig)
raw = json.loads(config_manager.config_file.read_text(encoding="utf-8"))
assert "cloud_mode" not in raw
def test_migration_creates_backup_of_old_config(self):
"""Config migration should create a .bak backup before overwriting."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
import json
old_config_data = {
"env": "dev",
"projects": {"main": str(temp_path / "main")},
"default_project": "main",
}
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
original_content = config_manager.config_file.read_text()
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
config_manager.load_config()
# Backup should exist with the original content
backup_path = config_manager.config_file.with_suffix(".json.bak")
assert backup_path.exists(), "Migration should create a backup file"
assert backup_path.read_text() == original_content
def test_no_backup_when_config_is_current_format(self):
"""No backup should be created when config is already in the current format."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
import json
# Write config in the current ProjectEntry format — no migration needed
current_config_data = {
"env": "dev",
"projects": {"main": {"path": str(temp_path / "main"), "mode": "local"}},
"default_project": "main",
}
config_manager.config_file.write_text(json.dumps(current_config_data, indent=2))
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
config_manager.load_config()
backup_path = config_manager.config_file.with_suffix(".json.bak")
assert not backup_path.exists(), "No backup should be created for current-format config"
class TestPlatformNativePathSeparators:
"""Test that config uses platform-native path separators."""
def test_project_paths_use_platform_native_separators_in_config(self, monkeypatch):
"""Test that project paths use platform-native separators when created."""
import platform
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Set up ConfigManager with temp directory
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create a project path
project_path = temp_path / "my" / "project"
project_path.mkdir(parents=True, exist_ok=True)
# Add project via ConfigManager
config = BasicMemoryConfig(projects={})
config.projects["test-project"] = ProjectEntry(path=str(project_path))
config_manager.save_config(config)
# Read the raw JSON file
import json
config_data = json.loads(config_manager.config_file.read_text())
# Verify path uses platform-native separators
saved_path = config_data["projects"]["test-project"]["path"]
# On Windows, should have backslashes; on Unix, forward slashes
if platform.system() == "Windows":
# Windows paths should contain backslashes
assert "\\" in saved_path or ":" in saved_path # C:\\ or \\UNC
assert "/" not in saved_path.replace(":/", "") # Exclude drive letter
else:
# Unix paths should use forward slashes
assert "/" in saved_path
# Should not force POSIX on non-Windows
assert saved_path == str(project_path)
def test_add_project_uses_platform_native_separators(self, monkeypatch):
"""Test that ConfigManager.add_project() uses platform-native separators."""
import platform
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Set up ConfigManager
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Initialize with empty projects
initial_config = BasicMemoryConfig(projects={})
config_manager.save_config(initial_config)
# Add project
project_path = temp_path / "new" / "project"
config_manager.add_project("new-project", str(project_path))
# Load and verify
config = config_manager.load_config()
saved_path = config.projects["new-project"].path
# Verify platform-native separators
if platform.system() == "Windows":
assert "\\" in saved_path or ":" in saved_path
else:
assert "/" in saved_path
assert saved_path == str(project_path)
def test_add_project_never_creates_directory(self):
"""Test that ConfigManager.add_project() is pure config management — no mkdir.
Directory creation is delegated to ProjectService via FileService, which
supports both local and cloud (S3) backends.
"""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
initial_config = BasicMemoryConfig(projects={})
config_manager.save_config(initial_config)
# Use a path that does not exist — ConfigManager should not create it
nonexistent_path = str(temp_path / "nonexistent" / "project")
config_manager.add_project("test-project", nonexistent_path)
# Check directory does NOT exist right after add_project(),
# before load_config() which triggers the model validator
assert not Path(nonexistent_path).exists()
# Verify project was persisted in config
config = config_manager.load_config()
assert "test-project" in config.projects
assert config.projects["test-project"].path == nonexistent_path
def test_model_post_init_uses_platform_native_separators(self, config_home, monkeypatch):
"""Test that model_post_init uses platform-native separators."""
import platform
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
# Create config without projects (triggers model_post_init to add main)
config = BasicMemoryConfig(projects={})
# Verify main project path uses platform-native separators
main_path = config.projects["main"].path
if platform.system() == "Windows":
# Windows: should have backslashes or drive letter
assert "\\" in main_path or ":" in main_path
else:
# Unix: should have forward slashes
assert "/" in main_path
class TestSemanticSearchConfig:
"""Test semantic search configuration options."""
def test_semantic_search_enabled_defaults_to_true_when_semantic_modules_are_available(
self, monkeypatch
):
"""Semantic search defaults on when fastembed and sqlite_vec are importable."""
import basic_memory.config as config_module
monkeypatch.delenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", raising=False)
monkeypatch.setattr(
config_module.importlib.util,
"find_spec",
lambda name: object() if name in {"fastembed", "sqlite_vec"} else None,
)
config = BasicMemoryConfig()
assert config.semantic_search_enabled is True
def test_semantic_search_enabled_defaults_to_false_when_any_semantic_module_is_unavailable(
self, monkeypatch
):
"""Semantic search defaults off when required semantic modules are missing."""
import basic_memory.config as config_module
monkeypatch.delenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", raising=False)
monkeypatch.setattr(
config_module.importlib.util,
"find_spec",
lambda name: object() if name == "fastembed" else None,
)
config = BasicMemoryConfig()
assert config.semantic_search_enabled is False
def test_semantic_search_enabled_env_var_overrides_dependency_default(self, monkeypatch):
"""Environment overrides should win over dependency-based defaults."""
import basic_memory.config as config_module
monkeypatch.setattr(config_module.importlib.util, "find_spec", lambda name: None)
monkeypatch.setenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", "true")
enabled = BasicMemoryConfig()
assert enabled.semantic_search_enabled is True
monkeypatch.setenv("BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED", "false")
disabled = BasicMemoryConfig()
assert disabled.semantic_search_enabled is False
def test_semantic_embedding_dimensions_defaults_to_none(self):
"""Dimensions should default to None, letting the provider choose."""
config = BasicMemoryConfig()
assert config.semantic_embedding_dimensions is None
def test_semantic_embedding_dimensions_can_be_set(self):
"""Explicit dimensions should be stored on the config object."""
config = BasicMemoryConfig(semantic_embedding_dimensions=1536)
assert config.semantic_embedding_dimensions == 1536
def test_semantic_embedding_forward_dimensions_defaults_to_none(self):
"""Dimension forwarding should default to provider auto-detection."""
config = BasicMemoryConfig()
assert config.semantic_embedding_forward_dimensions is None
def test_semantic_embedding_forward_dimensions_can_be_set(self):
"""Explicit LiteLLM dimension forwarding should be stored on the config object."""
config = BasicMemoryConfig(semantic_embedding_forward_dimensions=True)
assert config.semantic_embedding_forward_dimensions is True
def test_semantic_postgres_prepare_concurrency_defaults_to_4(self):
"""Postgres prepare concurrency should default to a conservative window of 4."""
config = BasicMemoryConfig()
assert config.semantic_postgres_prepare_concurrency == 4
def test_semantic_postgres_prepare_concurrency_validation(self):
"""Postgres prepare concurrency must stay within the bounded safe range."""
config = BasicMemoryConfig(semantic_postgres_prepare_concurrency=8)
assert config.semantic_postgres_prepare_concurrency == 8
with pytest.raises(Exception):
BasicMemoryConfig(semantic_postgres_prepare_concurrency=0)
with pytest.raises(Exception):
BasicMemoryConfig(semantic_postgres_prepare_concurrency=17)
def test_semantic_search_enabled_description_mentions_both_backends(self):
"""Description should not say 'SQLite only' anymore."""
field_info = BasicMemoryConfig.model_fields["semantic_search_enabled"]
assert "SQLite only" not in (field_info.description or "")
def test_semantic_min_similarity_defaults_to_055(self):
"""Threshold defaults to 0.55 to filter irrelevant vector results."""
config = BasicMemoryConfig()
assert config.semantic_min_similarity == 0.55
def test_semantic_min_similarity_bounds_validation(self):
"""Threshold must be between 0.0 and 1.0."""
config = BasicMemoryConfig(semantic_min_similarity=0.55)
assert config.semantic_min_similarity == 0.55
with pytest.raises(Exception):
BasicMemoryConfig(semantic_min_similarity=-0.1)
with pytest.raises(Exception):
BasicMemoryConfig(semantic_min_similarity=1.1)
def test_default_search_type_defaults_to_none(self):
"""default_search_type should be None by default (auto-detect)."""
config = BasicMemoryConfig()
assert config.default_search_type is None
def test_default_search_type_accepts_valid_values(self):
"""default_search_type accepts text, vector, hybrid."""
for search_type in ("text", "vector", "hybrid"):
config = BasicMemoryConfig(default_search_type=search_type)
assert config.default_search_type == search_type
def test_default_search_type_rejects_invalid_values(self):
"""default_search_type rejects unknown values."""
with pytest.raises(Exception):
BasicMemoryConfig(default_search_type="invalid")
class TestFormattingConfig:
"""Test file formatting configuration options."""
def test_format_on_save_defaults_to_false(self):
"""Test that format_on_save is disabled by default."""
config = BasicMemoryConfig()
assert config.format_on_save is False
def test_format_on_save_can_be_enabled(self):
"""Test that format_on_save can be set to True."""
config = BasicMemoryConfig(format_on_save=True)
assert config.format_on_save is True
def test_formatter_command_defaults_to_none(self):
"""Test that formatter_command defaults to None (uses built-in mdformat)."""
config = BasicMemoryConfig()
assert config.formatter_command is None
def test_formatter_command_can_be_set(self):
"""Test that formatter_command can be configured."""
config = BasicMemoryConfig(formatter_command="prettier --write {file}")
assert config.formatter_command == "prettier --write {file}"
def test_formatters_defaults_to_empty_dict(self):
"""Test that formatters defaults to empty dict."""
config = BasicMemoryConfig()
assert config.formatters == {}
def test_formatters_can_be_configured(self):
"""Test that per-extension formatters can be configured."""
config = BasicMemoryConfig(
formatters={
"md": "prettier --write {file}",
"json": "jq . {file} > {file}.tmp && mv {file}.tmp {file}",
}
)
assert config.formatters["md"] == "prettier --write {file}"
assert "json" in config.formatters
def test_formatter_timeout_defaults_to_5_seconds(self):
"""Test that formatter_timeout defaults to 5.0 seconds."""
config = BasicMemoryConfig()
assert config.formatter_timeout == 5.0
def test_formatter_timeout_can_be_customized(self):
"""Test that formatter_timeout can be set to a different value."""
config = BasicMemoryConfig(formatter_timeout=10.0)
assert config.formatter_timeout == 10.0
def test_formatter_timeout_must_be_positive(self):
"""Test that formatter_timeout validation rejects non-positive values."""
import pydantic
with pytest.raises(pydantic.ValidationError):
BasicMemoryConfig(formatter_timeout=0)
with pytest.raises(pydantic.ValidationError):
BasicMemoryConfig(formatter_timeout=-1)
def test_formatting_env_vars(self, monkeypatch):
"""Test that formatting config can be set via environment variables."""
monkeypatch.setenv("BASIC_MEMORY_FORMAT_ON_SAVE", "true")
monkeypatch.setenv("BASIC_MEMORY_FORMATTER_COMMAND", "prettier --write {file}")
monkeypatch.setenv("BASIC_MEMORY_FORMATTER_TIMEOUT", "10.0")
config = BasicMemoryConfig()
assert config.format_on_save is True
assert config.formatter_command == "prettier --write {file}"
assert config.formatter_timeout == 10.0
def test_formatters_env_var_json(self, monkeypatch):
"""Test that formatters dict can be set via JSON environment variable."""
import json
formatters_json = json.dumps({"md": "prettier --write {file}", "json": "jq . {file}"})
monkeypatch.setenv("BASIC_MEMORY_FORMATTERS", formatters_json)
config = BasicMemoryConfig()
assert config.formatters == {"md": "prettier --write {file}", "json": "jq . {file}"}
def test_save_and_load_formatting_config(self):
"""Test that formatting config survives save/load cycle."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create config with formatting settings
test_config = BasicMemoryConfig(
projects={"main": {"path": str(temp_path / "main")}},
format_on_save=True,
formatter_command="prettier --write {file}",
formatters={"md": "prettier --write {file}", "json": "prettier --write {file}"},
formatter_timeout=10.0,
)
config_manager.save_config(test_config)
# Load and verify
loaded_config = config_manager.load_config()
assert loaded_config.format_on_save is True
assert loaded_config.formatter_command == "prettier --write {file}"
assert loaded_config.formatters == {
"md": "prettier --write {file}",
"json": "prettier --write {file}",
}
assert loaded_config.formatter_timeout == 10.0
class TestProjectMode:
"""Test per-project routing mode configuration."""
def test_project_mode_defaults(self):
"""Test that ProjectMode enum has expected values."""
assert ProjectMode.LOCAL.value == "local"
assert ProjectMode.CLOUD.value == "cloud"
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.CLOUD
def test_set_project_mode_cloud(self):
"""Test setting a project to cloud mode."""
config = BasicMemoryConfig()
config.set_project_mode("research", ProjectMode.CLOUD)
assert config.get_project_mode("research") == ProjectMode.CLOUD
def test_set_project_mode_local_resets_to_default(self):
"""Test that setting a project back to LOCAL resets the entry's mode."""
config = BasicMemoryConfig()
# Need a project entry to set mode on
config.projects["research"] = ProjectEntry(path="/tmp/research")
config.set_project_mode("research", ProjectMode.CLOUD)
assert config.projects["research"].mode == ProjectMode.CLOUD
config.set_project_mode("research", ProjectMode.LOCAL)
assert config.projects["research"].mode == ProjectMode.LOCAL
assert config.get_project_mode("research") == ProjectMode.LOCAL
def test_cloud_api_key_defaults_to_none(self):
"""Test that cloud_api_key defaults to None."""
config = BasicMemoryConfig()
assert config.cloud_api_key is None
def test_cloud_api_key_can_be_set(self):
"""Test that cloud_api_key can be configured."""
config = BasicMemoryConfig(cloud_api_key="bmc_test123")
assert config.cloud_api_key == "bmc_test123"
def test_project_mode_round_trip(self):
"""Test that project mode survives save/load cycle."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create config with project mode and cloud_api_key
test_config = BasicMemoryConfig(
projects={
"main": {"path": str(temp_path / "main")},
"research": {"path": str(temp_path / "research"), "mode": "cloud"},
},
cloud_api_key="bmc_test123",
)
config_manager.save_config(test_config)
# Load and verify
loaded = config_manager.load_config()
assert loaded.cloud_api_key == "bmc_test123"
assert loaded.get_project_mode("research") == ProjectMode.CLOUD
assert loaded.get_project_mode("main") == ProjectMode.LOCAL
def test_backward_compat_loading_old_format_without_project_modes(self):
"""Test that old config files with Dict[str, str] projects are migrated."""
import json
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Write old-style config with Dict[str, str] projects
old_config_data = {
"env": "dev",
"projects": {"main": str(temp_path / "main")},
"default_project": "main",
"log_level": "INFO",
}
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
# Clear config cache
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
# Should load successfully with migration
config = config_manager.load_config()
assert config.cloud_api_key is None
assert config.get_project_mode("main") == ProjectMode.LOCAL
assert isinstance(config.projects["main"], ProjectEntry)
def test_project_list_includes_mode(self, config_home):
"""Test that project_list property includes mode information."""
config = BasicMemoryConfig(
projects={
"main": {"path": str(config_home / "main")},
"research": {"path": str(config_home / "research"), "mode": "cloud"},
},
)
project_list = config.project_list
modes_by_name = {p.name: p.mode for p in project_list}
assert modes_by_name["main"] == ProjectMode.LOCAL
assert modes_by_name["research"] == ProjectMode.CLOUD
def test_workspace_id_defaults_to_none(self):
"""Test that workspace_id on ProjectEntry defaults to None."""
entry = ProjectEntry(path="/tmp/test")
assert entry.workspace_id is None
def test_workspace_id_can_be_set(self):
"""Test that workspace_id can be configured on ProjectEntry."""
entry = ProjectEntry(
path="/tmp/test",
workspace_id="11111111-1111-1111-1111-111111111111",
)
assert entry.workspace_id == "11111111-1111-1111-1111-111111111111"
def test_default_workspace_defaults_to_none(self):
"""Test that default_workspace on BasicMemoryConfig defaults to None."""
config = BasicMemoryConfig()
assert config.default_workspace is None
def test_default_workspace_can_be_set(self):
"""Test that default_workspace can be configured."""
config = BasicMemoryConfig(default_workspace="22222222-2222-2222-2222-222222222222")
assert config.default_workspace == "22222222-2222-2222-2222-222222222222"
def test_workspace_fields_round_trip(self):
"""Test that workspace fields survive save/load cycle."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
test_config = BasicMemoryConfig(
projects={
"main": {"path": str(temp_path / "main")},
"research": {
"path": str(temp_path / "research"),
"mode": "cloud",
"workspace_id": "11111111-1111-1111-1111-111111111111",
},
},
default_workspace="22222222-2222-2222-2222-222222222222",
)
config_manager.save_config(test_config)
loaded = config_manager.load_config()
assert loaded.default_workspace == "22222222-2222-2222-2222-222222222222"
assert (
loaded.projects["research"].workspace_id == "11111111-1111-1111-1111-111111111111"
)
assert loaded.projects["main"].workspace_id is None
class TestConfigCacheMtimeInvalidation:
"""Test that config cache is invalidated when file is modified externally."""
def test_cache_returns_same_config_when_file_unchanged(self, config_home):
"""Verify cache hit when config file mtime has not changed."""
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
test_config = BasicMemoryConfig(
projects={"main": {"path": str(temp_path / "main")}},
default_project="main",
)
config_manager.save_config(test_config)
# First load populates cache
config1 = config_manager.load_config()
assert config1.default_project == "main"
# Second load should return cached config (same object)
config2 = config_manager.load_config()
assert config1 is config2
def test_cache_invalidated_when_file_modified(self, config_home):
"""Verify cache miss when config file is modified by another process."""
import json
import os
import time
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
test_config = BasicMemoryConfig(
projects={"main": {"path": str(temp_path / "main")}},
default_project="main",
)
config_manager.save_config(test_config)
# First load populates cache
config1 = config_manager.load_config()
assert config1.get_project_mode("main") == ProjectMode.LOCAL
# Simulate external process modifying the config file
config_data = json.loads(config_manager.config_file.read_text())
config_data["projects"]["main"]["mode"] = "cloud"
# Ensure mtime actually changes (some filesystems have 1s granularity)
time.sleep(0.05)
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
# Force mtime change on filesystems with coarse granularity
new_mtime = os.path.getmtime(config_manager.config_file) + 1
os.utime(config_manager.config_file, (new_mtime, new_mtime))
# Next load should detect mtime change and re-read
config2 = config_manager.load_config()
assert config2.get_project_mode("main") == ProjectMode.CLOUD
assert config1 is not config2
def test_save_config_resets_mtime(self, config_home):
"""Verify save_config clears both cache and mtime."""
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
test_config = BasicMemoryConfig(
projects={"main": {"path": str(temp_path / "main")}},
)
config_manager.save_config(test_config)
# Load to populate cache
config_manager.load_config()
assert basic_memory.config._CONFIG_CACHE is not None
assert basic_memory.config._CONFIG_MTIME is not None
assert basic_memory.config._CONFIG_SIZE is not None
# Save should clear all cache state
config_manager.save_config(test_config)
assert basic_memory.config._CONFIG_CACHE is None
assert basic_memory.config._CONFIG_MTIME is None
assert basic_memory.config._CONFIG_SIZE is None
class TestLocalSyncPathMigration:
"""Test migration that promotes local_sync_path into path for cloud projects."""
def test_migrate_promotes_local_sync_path_to_path(self):
"""When path is a cloud slug and local_sync_path is set, path becomes local_sync_path."""
data = {
"projects": {
"specs": {
"path": "specs",
"mode": "cloud",
"local_sync_path": "/Users/test/Documents/specs",
}
}
}
result = _migrate_legacy_projects(data)
assert result["projects"]["specs"]["path"] == "/Users/test/Documents/specs"
def test_migrate_does_not_overwrite_absolute_path(self):
"""When path is already absolute, migration should not change it."""
data = {
"projects": {
"specs": {
"path": "/Users/test/Documents/specs",
"mode": "cloud",
"local_sync_path": "/Users/test/Documents/specs",
}
}
}
result = _migrate_legacy_projects(data)
assert result["projects"]["specs"]["path"] == "/Users/test/Documents/specs"
def test_migrate_skips_entries_without_local_sync_path(self):
"""Entries without local_sync_path should not be modified."""
data = {
"projects": {
"cloud-only": {
"path": "cloud-only",
"mode": "cloud",
}
}
}
result = _migrate_legacy_projects(data)
assert result["projects"]["cloud-only"]["path"] == "cloud-only"
def test_migrate_handles_mixed_projects(self, tmp_path):
"""Migration handles a mix of local, cloud-only, and cloud-with-bisync projects."""
local_path = str(tmp_path / "local")
bisync_path = str(tmp_path / "bisync")
data = {
"projects": {
"local-proj": {"path": local_path, "mode": "local"},
"cloud-only": {"path": "cloud-only", "mode": "cloud"},
"cloud-bisync": {
"path": "cloud-bisync",
"mode": "cloud",
"local_sync_path": bisync_path,
},
}
}
result = _migrate_legacy_projects(data)
assert result["projects"]["local-proj"]["path"] == local_path
assert result["projects"]["cloud-only"]["path"] == "cloud-only"
assert result["projects"]["cloud-bisync"]["path"] == bisync_path
class TestAutoUpdateConfig:
"""Test auto-update configuration fields."""
def test_auto_update_defaults(self):
"""Auto-update should default on with a daily check interval."""
config = BasicMemoryConfig()
assert config.auto_update is True
assert config.update_check_interval == 86400
assert config.auto_update_last_checked_at is None
def test_auto_update_env_overrides(self, monkeypatch):
"""Environment variables should override auto-update defaults."""
monkeypatch.setenv("BASIC_MEMORY_AUTO_UPDATE", "false")
monkeypatch.setenv("BASIC_MEMORY_UPDATE_CHECK_INTERVAL", "3600")
config = BasicMemoryConfig()
assert config.auto_update is False
assert config.update_check_interval == 3600
def test_auto_update_round_trip_persistence(self):
"""Auto-update values should survive save/load cycle."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
checked_at = datetime.now()
test_config = BasicMemoryConfig(
projects={"main": {"path": str(temp_path / "main")}},
auto_update=False,
update_check_interval=7200,
auto_update_last_checked_at=checked_at,
)
config_manager.save_config(test_config)
loaded = config_manager.load_config()
assert loaded.auto_update is False
assert loaded.update_check_interval == 7200
assert loaded.auto_update_last_checked_at == checked_at