mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
b6e8c636ce
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>
1603 lines
69 KiB
Python
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
|